feat(im): 继续优化频道的各种代码(v4)优化卡片样式

im
YunaiV 2026-05-19 23:52:11 +08:00
parent 94e5fc00ac
commit 9a36cfe933
15 changed files with 433 additions and 335 deletions

View File

@ -344,5 +344,6 @@ export enum DICT_TYPE {
IM_RTC_CALL_STATUS = 'im_rtc_call_status', // IM 通话状态10=创建 / 20=进行中 / 30=已结束 IM_RTC_CALL_STATUS = 'im_rtc_call_status', // IM 通话状态10=创建 / 20=进行中 / 30=已结束
IM_RTC_CALL_END_REASON = 'im_rtc_call_end_reason', // IM 通话结束原因1=通话结束 / 2=已拒绝 / 3=已取消 / 4=无人接听 / 5=对方正忙 / 9=通话异常 IM_RTC_CALL_END_REASON = 'im_rtc_call_end_reason', // IM 通话结束原因1=通话结束 / 2=已拒绝 / 3=已取消 / 4=无人接听 / 5=对方正忙 / 9=通话异常
IM_RTC_PARTICIPANT_ROLE = 'im_rtc_participant_role', // IM 通话参与角色1=发起人 / 2=被邀请者 / 3=主动加入者 IM_RTC_PARTICIPANT_ROLE = 'im_rtc_participant_role', // IM 通话参与角色1=发起人 / 2=被邀请者 / 3=主动加入者
IM_RTC_PARTICIPANT_STATUS = 'im_rtc_participant_status' // IM 通话参与状态10=邀请中 / 20=已加入 / 30=已拒绝 / 40=未应答 / 50=已离开 IM_RTC_PARTICIPANT_STATUS = 'im_rtc_participant_status', // IM 通话参与状态10=邀请中 / 20=已加入 / 30=已拒绝 / 40=未应答 / 50=已离开
IM_CHANNEL_MATERIAL_TYPE = 'im_channel_material_type' // IM 频道素材内容类型1=富文本 / 2=外链
} }

View File

@ -1,26 +1,35 @@
<template> <template>
<!-- 公众号会话内大卡片封面 + 标题 + 摘要 --> <!-- 公众号会话内大卡片对齐微信公众号单图文卡封面 9:5 + 下方白底加粗标题条 -->
<div v-if="isChannelView" class="material-card" @click="onClick"> <div
<div class="title">{{ payload.title || '(无标题)' }}</div> v-if="isChannelView"
<img v-if="payload.coverUrl" class="cover" :src="payload.coverUrl" /> class="material-card channel-card cursor-pointer"
<div v-if="payload.summary" class="summary">{{ payload.summary }}</div> @click="onClick"
<span class="link">{{ payload.url ? '外链' : '查看详情' }}</span> >
<img v-if="payload.coverUrl" class="channel-cover" :src="payload.coverUrl" />
<div class="channel-title">{{ payload.title || '(无标题)' }}</div>
</div> </div>
<!-- 私聊 / 群聊里被转发的素材紧凑卡片标题左 + 小封面右 + 底部频道标识 --> <!-- 私聊 / 群聊里被转发的素材紧凑卡片标题 + 摘要在左小封面在右底部频道头像 + 名称对齐微信公众号转发卡 -->
<!-- TODO @AI转发后的消息无法点击打开 --> <div v-else class="material-card forward-card cursor-pointer" @click="onClick">
<div v-else class="material-card-forward" @click="onClick">
<div class="forward-body"> <div class="forward-body">
<div class="forward-title">{{ payload.title || '(无标题)' }}</div> <div class="forward-text">
<div class="forward-title">{{ payload.title || '(无标题)' }}</div>
<div v-if="payload.summary" class="forward-summary">{{ payload.summary }}</div>
</div>
<img v-if="payload.coverUrl" class="forward-cover" :src="payload.coverUrl" /> <img v-if="payload.coverUrl" class="forward-cover" :src="payload.coverUrl" />
</div> </div>
<div class="forward-footer"> <div class="forward-footer">
<Icon icon="ep:promotion" :size="12" /> <img
<span>频道消息</span> v-if="sourceChannel?.avatar"
class="forward-channel-avatar"
:src="sourceChannel.avatar"
/>
<Icon v-else icon="ep:promotion" :size="14" />
<span class="forward-channel-name">{{ sourceChannel?.name || '频道消息' }}</span>
</div> </div>
</div> </div>
<!-- TODO @ai需要注释下 --> <!-- 富文本详情全屏弹窗按需挂载destroy-on-close 关闭后释放 v-dompurify-html 解析的 DOM -->
<Dialog <Dialog
v-model="detailVisible" v-model="detailVisible"
:title="payload.title || '详情'" :title="payload.title || '详情'"
@ -37,23 +46,19 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { PropType } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import { getChannelMaterial } from '@/api/im/channel/material' import { getChannelMaterial } from '@/api/im/channel/material'
import { parseMessage, type MaterialMessage } from '@/views/im/utils/message' import { parseMessage, type MaterialMessage } from '@/views/im/utils/message'
import { useConversationStore } from '@/views/im/home/store/conversationStore' import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useChannelStore } from '@/views/im/home/store/channelStore'
import { ImConversationType } from '@/views/im/utils/constants' import { ImConversationType } from '@/views/im/utils/constants'
interface MessageInfo { const props = defineProps<{
materialId?: number
content: string content: string
} }>()
const props = defineProps({
message: { type: Object as PropType<MessageInfo>, required: true }
})
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const channelStore = useChannelStore()
/** 当前是否在公众号 / 频道会话里:决定走大卡片还是紧凑转发卡片 */ /** 当前是否在公众号 / 频道会话里:决定走大卡片还是紧凑转发卡片 */
const isChannelView = computed( const isChannelView = computed(
@ -62,28 +67,33 @@ const isChannelView = computed(
/** 反序列化 content JSON 为 payload 对象 */ /** 反序列化 content JSON 为 payload 对象 */
const payload = computed<MaterialMessage>( const payload = computed<MaterialMessage>(
() => parseMessage<MaterialMessage>(props.message.content) ?? {} () => parseMessage<MaterialMessage>(props.content) ?? {}
)
/** 来源频道;紧凑卡底部渲染头像 + 名称 */
const sourceChannel = computed(() =>
payload.value.channelId ? channelStore.getChannel(payload.value.channelId) : undefined
) )
const detailVisible = ref(false) const detailVisible = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const detailHtml = ref('') const detailHtml = ref('')
/** 点击行为url 非空跳外链;为空则按 materialId 拉富文本正文,全屏 dialog 渲染 */ /** 点击行为url 非空跳外链;为空则按 payload.materialId 拉富文本正文,全屏 dialog 渲染 */
const onClick = async () => { const onClick = async () => {
if (payload.value.url) { if (payload.value.url) {
// noopener,noreferrer window.opener / referrer // noopener,noreferrer window.opener / referrer
window.open(payload.value.url, '_blank', 'noopener,noreferrer') window.open(payload.value.url, '_blank', 'noopener,noreferrer')
return return
} }
if (!props.message.materialId) { if (!payload.value.materialId) {
return return
} }
detailVisible.value = true detailVisible.value = true
detailLoading.value = true detailLoading.value = true
detailHtml.value = '' detailHtml.value = ''
try { try {
const material = await getChannelMaterial(props.message.materialId) const material = await getChannelMaterial(payload.value.materialId)
detailHtml.value = material?.content ?? '' detailHtml.value = material?.content ?? ''
} catch (e) { } catch (e) {
console.error('[Material] 拉取正文失败', e) console.error('[Material] 拉取正文失败', e)
@ -94,119 +104,126 @@ const onClick = async () => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 公众号会话内大卡片:占父容器全宽,封面 + 标题 + 摘要纵向铺开 */ /* hover 阴影 + transition 用 SCSS 写更紧凑unocss 写成 hover: 一行还行,但 transition 缓动还得带类,反而散 */
/** TODO @AI有没可能 unocss 尽量替代掉; */
.material-card { .material-card {
display: flex;
flex-direction: column;
width: 100%;
padding: 14px 16px 12px;
background: var(--el-bg-color);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
cursor: pointer;
transition: box-shadow 0.15s ease; transition: box-shadow 0.15s ease;
&:hover { &:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
} }
}
.title { /* 公众号大卡片:封面 9:5 + 下方加粗标题条;纯 SCSS 写避免 unocss 偶发 arbitrary value 漏生成 */
font-size: 16px; .channel-card {
width: 100%;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
.channel-cover {
display: block;
width: 100%;
height: 200px;
object-fit: cover;
}
.channel-title {
padding: 12px 14px;
font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
margin-bottom: 10px;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.cover {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 4px;
background: var(--el-fill-color-light);
}
.summary {
margin-top: 10px;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.link {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-color-primary);
}
} }
/* 私聊 / 群聊转发紧凑卡片:标题左 + 小封面右 + 底部频道标识 */ /* 私聊 / 群聊转发卡片:标题 + 摘要左、封面右、底部频道头像 + 名称 */
.material-card-forward { .forward-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 280px; width: 260px;
padding: 10px 12px 8px; padding: 12px 14px 10px;
background: var(--el-bg-color); background: var(--el-bg-color);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter); border: 1px solid var(--el-border-color-lighter);
cursor: pointer; border-radius: 8px;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.forward-body { .forward-body {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: flex-start; align-items: flex-start;
.forward-title { .forward-text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 14px; display: flex;
font-weight: 500; flex-direction: column;
gap: 6px;
}
.forward-title {
font-size: 15px;
font-weight: 600;
line-height: 1.4; line-height: 1.4;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
word-break: break-all;
}
.forward-summary {
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
} }
.forward-cover { .forward-cover {
flex-shrink: 0;
width: 60px; width: 60px;
height: 60px; height: 60px;
object-fit: cover; object-fit: cover;
border-radius: 4px; border-radius: 4px;
background: var(--el-fill-color-light); background: var(--el-fill-color-light);
flex-shrink: 0;
} }
} }
.forward-footer { .forward-footer {
margin-top: 8px; margin-top: 10px;
padding-top: 6px; padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter); border-top: 1px solid var(--el-border-color-lighter);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
font-size: 11px; font-size: 12px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
.forward-channel-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.forward-channel-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
} }
/* 富文本详情article-content 内置 img / p / hN 用 :deep 全局生效unocss 无法穿透 scoped 边界 */
.material-detail-body { .material-detail-body {
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;

View File

@ -150,11 +150,7 @@
</div> </div>
<!-- 频道素材图文卡片点击拉富文本 / 跳外链 --> <!-- 频道素材图文卡片点击拉富文本 / 跳外链 -->
<!-- TODO @AI在对话界面里时目前碰到消息无法跳转的情况ps考虑到可以兼容到私聊群聊消息是不是把 materialId 也在 content 里存储一份说白了materialId 只是为了管理后台的检索其它地方尽量使用 content 里的 materialId 字段 --> <MaterialBubble v-else-if="isMaterial" :content="props.content" />
<MaterialBubble
v-else-if="isMaterial"
:message="{ content: props.content, materialId: props.materialId }"
/>
<!-- 未知类型降级 --> <!-- 未知类型降级 -->
<div <div
@ -206,8 +202,6 @@ const props = defineProps<{
uploadProgress?: number | null uploadProgress?: number | null
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */ /** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
mentions?: MentionCandidate[] mentions?: MentionCandidate[]
/** MATERIAL 气泡的素材编号;点击「查看详情」时拉富文本正文 */
materialId?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -101,92 +101,92 @@
:size="36" :size="36"
/> />
<div
class="flex flex-col gap-0.5"
:class="[
message.selfSend ? 'items-end' : '',
isMaterial ? 'w-[80%] min-w-[320px] max-w-[720px]' : 'max-w-[70%]'
]"
>
<!-- 群聊对方消息气泡上方显示发送者昵称 -->
<div <div
v-if="showSenderName" class="flex flex-col gap-0.5"
class="mb-0.5 text-12px text-[var(--el-text-color-secondary)] leading-tight" :class="[
message.selfSend ? 'items-end' : '',
// 360 / 70%
isMaterial ? 'w-[360px]' : 'max-w-[70%]'
]"
> >
{{ senderDisplayName }} <!-- 群聊对方消息气泡上方显示发送者昵称 -->
</div> <div
<div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }"> v-if="showSenderName"
<!-- 消息内容 type 9 类气泡统一由 MessageBubble 渲染 --> class="mb-0.5 text-12px text-[var(--el-text-color-secondary)] leading-tight"
<MessageBubble >
:type="message.type" {{ senderDisplayName }}
:content="message.content"
:self-send="message.selfSend"
:upload-progress="message.uploadProgress"
:mentions="textMentions"
:material-id="message.materialId"
@click-card="handleCardClick"
@open-merge="handleMergeOpen"
/>
<!-- 状态区自己消息展示发送状态 + 已读/群回执对方消息 + @自己时展示 @徽标 -->
<div class="flex gap-1.5 items-center text-base">
<template v-if="message.selfSend">
<Icon
v-if="showSendingLoading"
icon="ant-design:loading-outlined"
class="im-loading-spin"
/>
<Icon
v-else-if="message.status === ImMessageStatus.FAILED"
icon="ant-design:warning-filled"
color="#f56c6c"
class="cursor-pointer"
title="发送失败,点击重试"
@click="handleResend"
/>
<!-- 已读态私聊 -->
<span
v-else-if="privateReadLabel"
class="text-12px whitespace-nowrap"
:class="
message.status === ImMessageStatus.READ
? 'text-[#409eff]'
: 'text-[var(--el-text-color-secondary)]'
"
>
{{ privateReadLabel }}
</span>
<!-- 群回执点击弹 popover 展示已读 / 未读成员列表 -->
<MessageReadStatus
v-else-if="showGroupReadStatus"
:message="message"
:group-id="conversationStore.activeConversation?.targetId || 0"
:group-members="groupMembersForReadStatus"
class="text-12px whitespace-nowrap text-[var(--el-text-color-secondary)]"
/>
</template>
<!-- @ 我提示 -->
<el-tag
v-if="!message.selfSend && isAtMe"
type="danger"
size="small"
effect="plain"
class="ml-1"
>
@
</el-tag>
</div> </div>
<div class="flex gap-1.5 items-center" :class="{ 'flex-row-reverse': message.selfSend }">
<!-- 消息内容 type 9 类气泡统一由 MessageBubble 渲染 -->
<MessageBubble
:type="message.type"
:content="message.content"
:self-send="message.selfSend"
:upload-progress="message.uploadProgress"
:mentions="textMentions"
@click-card="handleCardClick"
@open-merge="handleMergeOpen"
/>
<!-- 状态区自己消息展示发送状态 + 已读/群回执对方消息 + @自己时展示 @徽标 -->
<div class="flex gap-1.5 items-center text-base">
<template v-if="message.selfSend">
<Icon
v-if="showSendingLoading"
icon="ant-design:loading-outlined"
class="im-loading-spin"
/>
<Icon
v-else-if="message.status === ImMessageStatus.FAILED"
icon="ant-design:warning-filled"
color="#f56c6c"
class="cursor-pointer"
title="发送失败,点击重试"
@click="handleResend"
/>
<!-- 已读态私聊 -->
<span
v-else-if="privateReadLabel"
class="text-12px whitespace-nowrap"
:class="
message.status === ImMessageStatus.READ
? 'text-[#409eff]'
: 'text-[var(--el-text-color-secondary)]'
"
>
{{ privateReadLabel }}
</span>
<!-- 群回执点击弹 popover 展示已读 / 未读成员列表 -->
<MessageReadStatus
v-else-if="showGroupReadStatus"
:message="message"
:group-id="conversationStore.activeConversation?.targetId || 0"
:group-members="groupMembersForReadStatus"
class="text-12px whitespace-nowrap text-[var(--el-text-color-secondary)]"
/>
</template>
<!-- @ 我提示 -->
<el-tag
v-if="!message.selfSend && isAtMe"
type="danger"
size="small"
effect="plain"
class="ml-1"
>
@
</el-tag>
</div>
</div>
<!-- 引用块气泡下方selfSend 时竖线在右侧 -->
<ReplyPreview
v-if="quote"
:quote="quote"
clickable
:mirrored="message.selfSend"
class="max-w-[280px]"
@locate="emit('locate', $event)"
/>
</div> </div>
<!-- 引用块气泡下方selfSend 时竖线在右侧 -->
<ReplyPreview
v-if="quote"
:quote="quote"
clickable
:mirrored="message.selfSend"
class="max-w-[280px]"
@locate="emit('locate', $event)"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -260,7 +260,11 @@ import ReplyPreview from './ReplyPreview.vue'
import TipSegments from './TipSegments.vue' import TipSegments from './TipSegments.vue'
import UserAvatar from '../../../../components/user/UserAvatar.vue' import UserAvatar from '../../../../components/user/UserAvatar.vue'
import MessageBubble from './MessageBubble.vue' import MessageBubble from './MessageBubble.vue'
import { IM_FORWARD_DIALOG_KEY, IM_MERGE_DETAIL_DIALOG_KEY, IM_RTC_REDIAL_KEY } from './forward/keys' import {
IM_FORWARD_DIALOG_KEY,
IM_MERGE_DETAIL_DIALOG_KEY,
IM_RTC_REDIAL_KEY
} from './forward/keys'
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect' import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
@ -305,7 +309,7 @@ const shouldShowTimeTip = computed(() => {
if (!props.message.sendTime) { if (!props.message.sendTime) {
return false return false
} }
if (props.message.type === ImMessageType.MATERIAL) { if (isMaterial.value) {
return true return true
} }
if (!props.prevMessage?.sendTime) { if (!props.prevMessage?.sendTime) {
@ -317,9 +321,25 @@ const shouldShowTimeTip = computed(() => {
/** 仅 MessageItem 自身仍要用到的 type 判定(其它分支已下沉到 MessageBubble */ /** 仅 MessageItem 自身仍要用到的 type 判定(其它分支已下沉到 MessageBubble */
const isVoice = computed(() => props.message.type === ImMessageType.VOICE) const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
const isMerge = computed(() => props.message.type === ImMessageType.MERGE) const isMerge = computed(() => props.message.type === ImMessageType.MERGE)
/** 频道素材消息:仿微信公众号样式 —— 卡片靠右、不显示左侧头像 */ /**
const isMaterial = computed(() => props.message.type === ImMessageType.MATERIAL) * 频道素材在频道会话内仿微信公众号样式居中 + 无头像
* 私聊 / 群聊里被转发过来的素材按 selfSend 走标准气泡布局自己右对方左带头像
*/
const isMaterial = computed(
() =>
props.message.type === ImMessageType.MATERIAL &&
conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** 私聊 / 群聊里被转发过来的素材:用紧凑卡片宽度(标题左 + 小封面右) */
const isForwardedMaterial = computed(
() => props.message.type === ImMessageType.MATERIAL && !isMaterial.value
)
/** 当前是否在公众号 / 频道会话内:限制右键菜单只展示转发 / 删除 */
const isChannelConversation = computed(
() => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
// ==================== / / 广 ==================== // ==================== / / 广 ====================
// tip + // tip +
@ -456,7 +476,6 @@ const showSendingLoading = computed(
() => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value) () => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value)
) )
// ==================== / / @ ==================== // ==================== / / @ ====================
/** 群聊 + 对方消息 时,在气泡上方显示发送者昵称 */ /** 群聊 + 对方消息 时,在气泡上方显示发送者昵称 */
@ -603,14 +622,42 @@ async function handleContextMenu(e: MouseEvent) {
return return
} }
const items: Array<{ type MenuItem = {
key: MenuKey key: MenuKey
name: string name: string
disabled?: boolean disabled?: boolean
divided?: boolean divided?: boolean
danger?: boolean danger?: boolean
icon?: string icon?: string
}> = [] }
// + / / / /
if (isChannelConversation.value) {
const channelItems: MenuItem[] = []
if (canForward.value) {
channelItems.push({
key: MENU_KEYS.FORWARD,
name: '转发',
icon: 'ant-design:share-alt-outlined'
})
}
channelItems.push({
key: MENU_KEYS.DELETE,
name: '删除',
icon: 'ant-design:delete-outlined',
divided: true,
danger: true
})
uiStore.openContextMenu({ x: e.clientX, y: e.clientY }, channelItems, async (item) => {
if (item.key === MENU_KEYS.FORWARD) {
handleForward()
} else if (item.key === MENU_KEYS.DELETE) {
handleDelete()
}
})
return
}
const items: MenuItem[] = []
// //
if (props.message.type === ImMessageType.TEXT) { if (props.message.type === ImMessageType.TEXT) {
items.push({ items.push({

View File

@ -164,8 +164,6 @@
</div> </div>
<!-- 底部输入框频道单向消息无需输入框多选模式底栏作为浮层盖在上面保持下方输入框尺寸不变 --> <!-- 底部输入框频道单向消息无需输入框多选模式底栏作为浮层盖在上面保持下方输入框尺寸不变 -->
<!-- TODO @AI暂时去掉频道的右键引用多选 -->
<!-- TODO @AI转发时不允许选择频道这块要屏蔽下 -->
<div v-if="!isChannel" class="relative"> <div v-if="!isChannel" class="relative">
<MessageInput /> <MessageInput />
<MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" /> <MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />

View File

@ -46,7 +46,9 @@
v-if="state.mode === ImForwardMode.MERGE && mergePreview" v-if="state.mode === ImForwardMode.MERGE && mergePreview"
class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]" class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
> >
<div class="px-3 py-2 text-sm font-medium truncate text-[var(--el-text-color-primary)]"> <div
class="px-3 py-2 text-sm font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ mergePreview.title }} {{ mergePreview.title }}
</div> </div>
<div class="flex flex-col px-3 pb-2 gap-0.5"> <div class="flex flex-col px-3 pb-2 gap-0.5">
@ -122,11 +124,7 @@
</ConversationPickerPanel> </ConversationPickerPanel>
<!-- 好友视图选好友建群后转发 --> <!-- 好友视图选好友建群后转发 -->
<FriendPickerPanel <FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
v-else
v-model:selected-ids="selectedFriendIds"
:friends="friends"
/>
</div> </div>
<!-- 好友视图的 dialog footer建群并转发 --> <!-- 好友视图的 dialog footer建群并转发 -->
@ -200,11 +198,7 @@ const emojiVisible = ref(false)
defineExpose({ defineExpose({
/** 打开转发弹窗reset → 灌参 → visible=true */ /** 打开转发弹窗reset → 灌参 → visible=true */
open(opts: { open(opts: { mode: ImForwardModeValue; messages: Message[]; sourceConversation: Conversation }) {
mode: ImForwardModeValue
messages: Message[]
sourceConversation: Conversation
}) {
state.mode = opts.mode state.mode = opts.mode
state.messages = opts.messages state.messages = opts.messages
state.sourceConversation = opts.sourceConversation state.sourceConversation = opts.sourceConversation
@ -231,9 +225,11 @@ const confirmButtonText = computed(() =>
selectedKeys.value.length > 1 ? `分别发送(${selectedKeys.value.length}` : '发送' selectedKeys.value.length > 1 ? `分别发送(${selectedKeys.value.length}` : '发送'
) )
/** 候选会话:从 store 拿排序后的列表(转发回原会话也允许,与微信一致) */ /** 候选会话:从 store 拿排序后的列表(转发回原会话也允许,与微信一致);公众号 / 频道单向消息不接受转发,从候选里剔除 */
const candidateConversations = computed<Conversation[]>( const candidateConversations = computed<Conversation[]>(() =>
() => conversationStore.getSortedConversations conversationStore.getSortedConversations.filter(
(conversation) => conversation.type !== ImConversationType.CHANNEL
)
) )
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */ /** 好友视图候选列表:直接复用 friendStore Lite 视图 */

View File

@ -17,9 +17,8 @@
<el-form-item label="频道名称" prop="name"> <el-form-item label="频道名称" prop="name">
<el-input v-model="formData.name" placeholder="如 系统公告" /> <el-input v-model="formData.name" placeholder="如 系统公告" />
</el-form-item> </el-form-item>
<!-- TODO @AI使用上传组件必须传递 -->
<el-form-item label="频道头像" prop="avatar"> <el-form-item label="频道头像" prop="avatar">
<el-input v-model="formData.avatar" placeholder="头像 URL" /> <UploadImg v-model="formData.avatar" :limit="1" />
</el-form-item> </el-form-item>
<el-form-item label="排序" prop="sort"> <el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" controls-position="right" /> <el-input-number v-model="formData.sort" :min="0" controls-position="right" />
@ -71,6 +70,7 @@ const formRules = reactive({
{ pattern: /^[a-z][a-z0-9_]*$/, message: '只能由小写字母 / 数字 / 下划线组成,且以字母开头', trigger: 'blur' } { pattern: /^[a-z][a-z0-9_]*$/, message: '只能由小写字母 / 数字 / 下划线组成,且以字母开头', trigger: 'blur' }
], ],
name: [{ required: true, message: '频道名称不能为空', trigger: 'blur' }], name: [{ required: true, message: '频道名称不能为空', trigger: 'blur' }],
avatar: [{ required: true, message: '频道头像不能为空', trigger: 'change' }],
sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }], sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
}) })

View File

@ -0,0 +1,33 @@
<template>
<el-select v-model="channelId" class="!w-full">
<el-option v-for="c in channelList" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</template>
<script lang="ts" setup>
import * as ChannelApi from '@/api/im/manager/channel'
/** 频道下拉选择 **/
defineOptions({ name: 'ImChannelSelect' })
const props = defineProps({
modelValue: { type: Number, default: undefined }
})
const emit = defineEmits(['update:modelValue'])
const channelId = computed({
get: () => props.modelValue,
set: (val: any) => emit('update:modelValue', val)
})
const channelList = ref<ChannelApi.ImManagerChannelVO[]>([])
const getList = async () => {
channelList.value = await ChannelApi.getSimpleChannelList()
}
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@ -151,37 +151,38 @@ const getList = async () => {
} }
} }
/** 搜索 */ /** 搜索按钮操作 */
const handleQuery = () => { const handleQuery = () => {
queryParams.pageNo = 1 queryParams.pageNo = 1
getList() getList()
} }
/** 重置 */ /** 重置按钮操作 */
const resetQuery = () => { const resetQuery = () => {
queryFormRef.value.resetFields() queryFormRef.value.resetFields()
handleQuery() handleQuery()
} }
/** 打开新增 / 编辑弹窗 */ /** 添加/修改操作 */
const formRef = ref() const formRef = ref()
const openForm = (type: string, id?: number) => { const openForm = (type: string, id?: number) => {
formRef.value.open(type, id) formRef.value.open(type, id)
} }
/** 删除 */ /** 删除按钮操作 */
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
//
await message.delConfirm() await message.delConfirm()
} catch { //
return await ChannelApi.deleteManagerChannel(id)
} message.success(t('common.delSuccess'))
await ChannelApi.deleteManagerChannel(id) //
message.success(t('common.delSuccess')) await getList()
await getList() } catch {}
} }
/** 初始化 **/ /** 初始化 */
onMounted(() => { onMounted(() => {
getList() getList()
}) })

View File

@ -10,18 +10,22 @@
<el-form-item label="所属频道" prop="channelId"> <el-form-item label="所属频道" prop="channelId">
<ChannelSelect v-model="formData.channelId" placeholder="请选择频道" /> <ChannelSelect v-model="formData.channelId" placeholder="请选择频道" />
</el-form-item> </el-form-item>
<!-- TODO @AI是不是内容类型在考虑优化下1富文本2外链更简介注意需要插入到字典里 -->
<el-form-item label="内容类型" prop="type"> <el-form-item label="内容类型" prop="type">
<el-radio-group v-model="formData.type"> <el-radio-group v-model="formData.type">
<el-radio :value="1">站内富文本</el-radio> <el-radio
<el-radio :value="2">外链</el-radio> v-for="dict in getIntDictOptions(DICT_TYPE.IM_CHANNEL_MATERIAL_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="标题" prop="title"> <el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="图文标题" maxlength="128" show-word-limit /> <el-input v-model="formData.title" placeholder="图文标题" maxlength="128" show-word-limit />
</el-form-item> </el-form-item>
<el-form-item label="封面图" prop="coverUrl"> <el-form-item label="封面图" prop="coverUrl">
<el-input v-model="formData.coverUrl" placeholder="封面图 URL" /> <UploadImg v-model="formData.coverUrl" :limit="1" />
</el-form-item> </el-form-item>
<el-form-item label="摘要" prop="summary"> <el-form-item label="摘要" prop="summary">
<el-input <el-input
@ -33,14 +37,9 @@
show-word-limit show-word-limit
/> />
</el-form-item> </el-form-item>
<!-- 内容类型为站内富文本时展示 content 富文本输入外链时展示 url 输入 --> <!-- 内容类型为站内富文本时展示 content 富文本编辑器外链时展示 url 输入 -->
<el-form-item v-if="formData.type === 1" label="正文" prop="content"> <el-form-item v-if="formData.type === 1" label="正文" prop="content">
<el-input <Editor v-model="formData.content" height="320px" />
v-model="formData.content"
placeholder="富文本 HTML"
type="textarea"
:rows="8"
/>
</el-form-item> </el-form-item>
<el-form-item v-else label="跳转链接" prop="url"> <el-form-item v-else label="跳转链接" prop="url">
<el-input v-model="formData.url" placeholder="https://example.com/..." /> <el-input v-model="formData.url" placeholder="https://example.com/..." />
@ -54,6 +53,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as MaterialApi from '@/api/im/manager/channel/material' import * as MaterialApi from '@/api/im/manager/channel/material'
import ChannelSelect from '../list/components/ChannelSelect.vue' import ChannelSelect from '../list/components/ChannelSelect.vue'

View File

@ -0,0 +1,35 @@
<template>
<el-select v-model="materialId" :disabled="!channelId" class="!w-full">
<el-option v-for="m in materialList" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
</template>
<script lang="ts" setup>
import * as MaterialApi from '@/api/im/manager/channel/material'
/** 素材下拉选择;按 channelId 联动 */
defineOptions({ name: 'ImMaterialSelect' })
const props = defineProps({
modelValue: { type: Number, default: undefined },
channelId: { type: Number, default: undefined }
})
const emit = defineEmits(['update:modelValue'])
const materialId = computed({
get: () => props.modelValue,
set: (val: any) => emit('update:modelValue', val)
})
const materialList = ref<MaterialApi.ImManagerChannelMaterialVO[]>([])
/** 切换频道时拉取该频道下的素材 */
watch(
() => props.channelId,
async (id) => {
emit('update:modelValue', undefined)
materialList.value = id ? await MaterialApi.getSimpleManagerChannelMaterialList(id) : []
},
{ immediate: true }
)
</script>

View File

@ -48,6 +48,11 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="频道" align="center" prop="channelName" width="120" /> <el-table-column label="频道" align="center" prop="channelName" width="120" />
<el-table-column label="内容类型" align="center" prop="type" width="100">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IM_CHANNEL_MATERIAL_TYPE" :value="row.type" />
</template>
</el-table-column>
<el-table-column <el-table-column
label="标题" label="标题"
align="left" align="left"
@ -62,14 +67,6 @@
min-width="180" min-width="180"
show-overflow-tooltip show-overflow-tooltip
/> />
<el-table-column label="跳转" align="center" prop="url" width="80">
<template #default="scope">
<el-link v-if="scope.row.url" type="primary" :href="scope.row.url" target="_blank"
>外链</el-link
>
<el-tag v-else type="info" size="small">站内</el-tag>
</template>
</el-table-column>
<el-table-column <el-table-column
label="创建时间" label="创建时间"
align="center" align="center"
@ -110,6 +107,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import * as MaterialApi from '@/api/im/manager/channel/material' import * as MaterialApi from '@/api/im/manager/channel/material'
import ChannelSelect from '../list/components/ChannelSelect.vue' import ChannelSelect from '../list/components/ChannelSelect.vue'
@ -143,34 +141,35 @@ const getList = async () => {
} }
} }
/** 搜索 */ /** 搜索按钮操作 */
const handleQuery = () => { const handleQuery = () => {
queryParams.pageNo = 1 queryParams.pageNo = 1
getList() getList()
} }
/** 重置 */ /** 重置按钮操作 */
const resetQuery = () => { const resetQuery = () => {
queryFormRef.value.resetFields() queryFormRef.value.resetFields()
handleQuery() handleQuery()
} }
/** 打开新增 / 编辑弹窗 */ /** 添加/修改操作 */
const formRef = ref() // Ref const formRef = ref()
const openForm = (type: string, id?: number) => { const openForm = (type: string, id?: number) => {
formRef.value.open(type, id) formRef.value.open(type, id)
} }
/** 删除 */ /** 删除按钮操作 */
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
//
await message.delConfirm() await message.delConfirm()
} catch { //
return await MaterialApi.deleteManagerChannelMaterial(id)
} message.success(t('common.delSuccess'))
await MaterialApi.deleteManagerChannelMaterial(id) //
message.success(t('common.delSuccess')) await getList()
await getList() } catch {}
} }
/** 初始化 */ /** 初始化 */

View File

@ -8,44 +8,41 @@
v-loading="formLoading" v-loading="formLoading"
> >
<el-form-item label="所属频道" prop="channelId"> <el-form-item label="所属频道" prop="channelId">
<el-select <ChannelSelect v-model="formData.channelId" placeholder="请选择频道(用于加载素材)" />
v-model="formData.channelId"
placeholder="请选择频道(用于加载素材)"
class="!w-full"
@change="onChannelChange"
>
<el-option v-for="c in props.channelList" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="素材" prop="materialId"> <el-form-item label="素材" prop="materialId">
<el-select <MaterialSelect
v-model="formData.materialId" v-model="formData.materialId"
:channel-id="formData.channelId"
placeholder="请选择素材" placeholder="请选择素材"
class="!w-full" />
:disabled="!formData.channelId"
>
<el-option
v-for="m in materialList"
:key="m.id"
:label="m.title"
:value="m.id"
/>
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="受众"> <el-form-item label="受众">
<el-radio-group v-model="targetType"> <el-radio-group v-model="formData.receiverUserType">
<el-radio value="all">全员</el-radio> <el-radio value="all">全员</el-radio>
<el-radio value="users">指定用户</el-radio> <el-radio value="users">指定用户</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<!-- TODO @AIuserselect 组件 --> <el-form-item
<el-form-item v-if="targetType === 'users'" label="接收用户" prop="receiverUserIds"> v-if="formData.receiverUserType === 'users'"
<el-input label="接收用户"
v-model="receiverInput" prop="receiverUserIds"
placeholder="多个用户编号用英文逗号分隔,如 1,1024,2048" >
type="textarea" <!-- TODO @芋艿后续换成 userselect 组件 -->
:rows="2" <el-select
/> v-model="formData.receiverUserIds"
multiple
filterable
placeholder="选择接收用户"
class="!w-full"
>
<el-option
v-for="user in userList"
:key="user.id"
:label="user.nickname"
:value="user.id"
/>
</el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -56,75 +53,51 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
// TODO @AI user form
import * as MessageApi from '@/api/im/manager/channel/message' import * as MessageApi from '@/api/im/manager/channel/message'
import * as MaterialApi from '@/api/im/manager/channel/material' import * as UserApi from '@/api/system/user'
import type { ImManagerChannelVO } from '@/api/im/manager/channel' import ChannelSelect from '../list/components/ChannelSelect.vue'
import MaterialSelect from '../material/components/MaterialSelect.vue'
defineOptions({ name: 'ImChannelMessageSendForm' }) defineOptions({ name: 'ImChannelMessageSendForm' })
const props = defineProps<{ channelList: ImManagerChannelVO[] }>() const message = useMessage() //
const dialogVisible = ref(false) //
const { t } = useI18n() const formLoading = ref(false) //
const message = useMessage()
const dialogVisible = ref(false)
const formLoading = ref(false)
const formData = ref({ const formData = ref({
channelId: undefined as number | undefined, channelId: undefined as number | undefined,
materialId: undefined as number | undefined materialId: undefined as number | undefined,
receiverUserType: 'all' as 'all' | 'users', // /
receiverUserIds: [] as number[]
}) })
const targetType = ref<'all' | 'users'>('all') const userList = ref<UserApi.UserVO[]>([]) //
const receiverInput = ref('')
const materialList = ref<MaterialApi.ImManagerChannelMaterialVO[]>([])
const formRules = reactive({ const formRules = reactive({
channelId: [{ required: true, message: '请选择频道', trigger: 'change' }], channelId: [{ required: true, message: '请选择频道', trigger: 'change' }],
materialId: [{ required: true, message: '请选择素材', trigger: 'change' }] materialId: [{ required: true, message: '请选择素材', trigger: 'change' }],
receiverUserIds: [{ required: true, message: '请至少选择一个接收用户', trigger: 'change' }]
}) })
const formRef = ref() const formRef = ref() // Ref
const open = () => { /** 打开弹窗 */
const open = async () => {
dialogVisible.value = true dialogVisible.value = true
resetForm() resetForm()
//
userList.value = await UserApi.getSimpleUserList()
} }
defineExpose({ open }) defineExpose({ open }) // open
const emit = defineEmits(['success']) const emit = defineEmits(['success']) // success
/** 切换频道时加载该频道下的素材列表 */
const onChannelChange = async (channelId: number | undefined) => {
formData.value.materialId = undefined
materialList.value = []
if (!channelId) return
const page = await MaterialApi.getManagerChannelMaterialPage({
pageNo: 1,
pageSize: 100,
channelId
} as any)
materialList.value = page.list
}
/** 提交表单 */
const submitForm = async () => { const submitForm = async () => {
await formRef.value.validate() await formRef.value.validate()
let receiverUserIds: number[] | undefined
if (targetType.value === 'users') {
receiverUserIds = receiverInput.value
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => Number(s))
.filter((n) => Number.isFinite(n))
if (!receiverUserIds || receiverUserIds.length === 0) {
message.error('请输入至少一个接收用户编号')
return
}
}
formLoading.value = true formLoading.value = true
try { try {
await MessageApi.sendManagerChannelMessage({ await MessageApi.sendManagerChannelMessage({
materialId: formData.value.materialId!, materialId: formData.value.materialId!,
receiverUserIds receiverUserIds:
formData.value.receiverUserType === 'users' ? formData.value.receiverUserIds : undefined
}) })
message.success('推送成功') message.success('推送成功')
dialogVisible.value = false dialogVisible.value = false
@ -134,11 +107,14 @@ const submitForm = async () => {
} }
} }
/** 重置表单 */
const resetForm = () => { const resetForm = () => {
formData.value = { channelId: undefined, materialId: undefined } formData.value = {
targetType.value = 'all' channelId: undefined,
receiverInput.value = '' materialId: undefined,
materialList.value = [] receiverUserType: 'all',
receiverUserIds: []
}
formRef.value?.resetFields() formRef.value?.resetFields()
} }
</script> </script>

View File

@ -123,36 +123,38 @@ const getList = async () => {
} }
} }
/** 搜索 */ /** 搜索按钮操作 */
const handleQuery = () => { const handleQuery = () => {
queryParams.pageNo = 1 queryParams.pageNo = 1
getList() getList()
} }
/** 重置 */ /** 重置按钮操作 */
const resetQuery = () => { const resetQuery = () => {
queryFormRef.value.resetFields() queryFormRef.value.resetFields()
handleQuery() handleQuery()
} }
/** 打开「立即推送」弹窗 */ /** 打开「立即推送」弹窗 */
const sendFormRef = ref() // Ref const sendFormRef = ref()
const openSendForm = () => { const openSendForm = () => {
sendFormRef.value.open() sendFormRef.value.open()
} }
/** 删除 */ /** 删除按钮操作 */
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
//
await message.delConfirm() await message.delConfirm()
} catch { //
return await MessageApi.deleteManagerChannelMessage(id)
} message.success(t('common.delSuccess'))
await MessageApi.deleteManagerChannelMessage(id) //
message.success(t('common.delSuccess')) await getList()
await getList() } catch {}
} }
/** 初始化 */
onMounted(() => { onMounted(() => {
getList() getList()
}) })

View File

@ -325,19 +325,18 @@ export interface MergeMessageItem {
/** 合并转发消息 payload对齐后端 MergeMessage */ /** 合并转发消息 payload对齐后端 MergeMessage */
export interface MergeMessage { export interface MergeMessage {
/** 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」 */ title: string // 合并标题;例:「张三和李四的聊天记录」「群聊的聊天记录」
title: string messages: MergeMessageItem[] // 内嵌的完整消息快照
/** 内嵌的完整消息快照 */
messages: MergeMessageItem[]
} }
/** 频道素材消息 payload对齐后端 MaterialMessage */ /** 频道素材消息 payload对齐后端 MaterialMessage */
export interface MaterialMessage { export interface MaterialMessage {
materialId?: number
channelId?: number // 频道编号;转发后渲染卡片底部的频道头像 + 名称
title?: string title?: string
coverUrl?: string coverUrl?: string
summary?: string summary?: string
/** 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url */ url?: string // 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url
url?: string
} }
// ==================== 合并转发 payload 构造 ==================== // ==================== 合并转发 payload 构造 ====================