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

View File

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

View File

@ -101,92 +101,92 @@
: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
v-if="showSenderName"
class="mb-0.5 text-12px text-[var(--el-text-color-secondary)] leading-tight"
class="flex flex-col gap-0.5"
:class="[
message.selfSend ? 'items-end' : '',
// 360 / 70%
isMaterial ? 'w-[360px]' : 'max-w-[70%]'
]"
>
{{ senderDisplayName }}
</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"
: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
v-if="showSenderName"
class="mb-0.5 text-12px text-[var(--el-text-color-secondary)] leading-tight"
>
{{ senderDisplayName }}
</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>
<!-- 引用块气泡下方selfSend 时竖线在右侧 -->
<ReplyPreview
v-if="quote"
:quote="quote"
clickable
:mirrored="message.selfSend"
class="max-w-[280px]"
@locate="emit('locate', $event)"
/>
</div>
</div>
</div>
</template>
@ -260,7 +260,11 @@ import ReplyPreview from './ReplyPreview.vue'
import TipSegments from './TipSegments.vue'
import UserAvatar from '../../../../components/user/UserAvatar.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 type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
@ -305,7 +309,7 @@ const shouldShowTimeTip = computed(() => {
if (!props.message.sendTime) {
return false
}
if (props.message.type === ImMessageType.MATERIAL) {
if (isMaterial.value) {
return true
}
if (!props.prevMessage?.sendTime) {
@ -317,9 +321,25 @@ const shouldShowTimeTip = computed(() => {
/** 仅 MessageItem 自身仍要用到的 type 判定(其它分支已下沉到 MessageBubble */
const isVoice = computed(() => props.message.type === ImMessageType.VOICE)
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 +
@ -456,7 +476,6 @@ const showSendingLoading = computed(
() => props.message.status === ImMessageStatus.SENDING && (!isUploading.value || isVoice.value)
)
// ==================== / / @ ====================
/** 群聊 + 对方消息 时,在气泡上方显示发送者昵称 */
@ -603,14 +622,42 @@ async function handleContextMenu(e: MouseEvent) {
return
}
const items: Array<{
type MenuItem = {
key: MenuKey
name: string
disabled?: boolean
divided?: boolean
danger?: boolean
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) {
items.push({

View File

@ -164,8 +164,6 @@
</div>
<!-- 底部输入框频道单向消息无需输入框多选模式底栏作为浮层盖在上面保持下方输入框尺寸不变 -->
<!-- TODO @AI暂时去掉频道的右键引用多选 -->
<!-- TODO @AI转发时不允许选择频道这块要屏蔽下 -->
<div v-if="!isChannel" class="relative">
<MessageInput />
<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"
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 }}
</div>
<div class="flex flex-col px-3 pb-2 gap-0.5">
@ -122,11 +124,7 @@
</ConversationPickerPanel>
<!-- 好友视图选好友建群后转发 -->
<FriendPickerPanel
v-else
v-model:selected-ids="selectedFriendIds"
:friends="friends"
/>
<FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
</div>
<!-- 好友视图的 dialog footer建群并转发 -->
@ -200,11 +198,7 @@ const emojiVisible = ref(false)
defineExpose({
/** 打开转发弹窗reset → 灌参 → visible=true */
open(opts: {
mode: ImForwardModeValue
messages: Message[]
sourceConversation: Conversation
}) {
open(opts: { mode: ImForwardModeValue; messages: Message[]; sourceConversation: Conversation }) {
state.mode = opts.mode
state.messages = opts.messages
state.sourceConversation = opts.sourceConversation
@ -231,9 +225,11 @@ const confirmButtonText = computed(() =>
selectedKeys.value.length > 1 ? `分别发送(${selectedKeys.value.length}` : '发送'
)
/** 候选会话:从 store 拿排序后的列表(转发回原会话也允许,与微信一致) */
const candidateConversations = computed<Conversation[]>(
() => conversationStore.getSortedConversations
/** 候选会话:从 store 拿排序后的列表(转发回原会话也允许,与微信一致);公众号 / 频道单向消息不接受转发,从候选里剔除 */
const candidateConversations = computed<Conversation[]>(() =>
conversationStore.getSortedConversations.filter(
(conversation) => conversation.type !== ImConversationType.CHANNEL
)
)
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */

View File

@ -17,9 +17,8 @@
<el-form-item label="频道名称" prop="name">
<el-input v-model="formData.name" placeholder="如 系统公告" />
</el-form-item>
<!-- TODO @AI使用上传组件必须传递 -->
<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 label="排序" prop="sort">
<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' }
],
name: [{ required: true, message: '频道名称不能为空', trigger: 'blur' }],
avatar: [{ required: true, message: '频道头像不能为空', trigger: 'change' }],
sort: [{ 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 = () => {
queryParams.pageNo = 1
getList()
}
/** 重置 */
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 打开新增 / 编辑弹窗 */
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除 */
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
} catch {
return
}
await ChannelApi.deleteManagerChannel(id)
message.success(t('common.delSuccess'))
await getList()
//
await ChannelApi.deleteManagerChannel(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 初始化 **/
/** 初始化 */
onMounted(() => {
getList()
})

View File

@ -10,18 +10,22 @@
<el-form-item label="所属频道" prop="channelId">
<ChannelSelect v-model="formData.channelId" placeholder="请选择频道" />
</el-form-item>
<!-- TODO @AI是不是内容类型在考虑优化下1富文本2外链更简介注意需要插入到字典里 -->
<el-form-item label="内容类型" prop="type">
<el-radio-group v-model="formData.type">
<el-radio :value="1">站内富文本</el-radio>
<el-radio :value="2">外链</el-radio>
<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-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="图文标题" maxlength="128" show-word-limit />
</el-form-item>
<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 label="摘要" prop="summary">
<el-input
@ -33,14 +37,9 @@
show-word-limit
/>
</el-form-item>
<!-- 内容类型为站内富文本时展示 content 富文本输入外链时展示 url 输入 -->
<!-- 内容类型为站内富文本时展示 content 富文本编辑器外链时展示 url 输入 -->
<el-form-item v-if="formData.type === 1" label="正文" prop="content">
<el-input
v-model="formData.content"
placeholder="富文本 HTML"
type="textarea"
:rows="8"
/>
<Editor v-model="formData.content" height="320px" />
</el-form-item>
<el-form-item v-else label="跳转链接" prop="url">
<el-input v-model="formData.url" placeholder="https://example.com/..." />
@ -54,6 +53,7 @@
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as MaterialApi from '@/api/im/manager/channel/material'
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>
</el-table-column>
<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
label="标题"
align="left"
@ -62,14 +67,6 @@
min-width="180"
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
label="创建时间"
align="center"
@ -110,6 +107,7 @@
</template>
<script lang="ts" setup>
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import * as MaterialApi from '@/api/im/manager/channel/material'
import ChannelSelect from '../list/components/ChannelSelect.vue'
@ -143,34 +141,35 @@ const getList = async () => {
}
}
/** 搜索 */
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置 */
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 打开新增 / 编辑弹窗 */
const formRef = ref() // Ref
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除 */
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
} catch {
return
}
await MaterialApi.deleteManagerChannelMaterial(id)
message.success(t('common.delSuccess'))
await getList()
//
await MaterialApi.deleteManagerChannelMaterial(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 初始化 */

View File

@ -8,44 +8,41 @@
v-loading="formLoading"
>
<el-form-item label="所属频道" prop="channelId">
<el-select
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>
<ChannelSelect v-model="formData.channelId" placeholder="请选择频道(用于加载素材)" />
</el-form-item>
<el-form-item label="素材" prop="materialId">
<el-select
<MaterialSelect
v-model="formData.materialId"
:channel-id="formData.channelId"
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 label="受众">
<el-radio-group v-model="targetType">
<el-radio-group v-model="formData.receiverUserType">
<el-radio value="all">全员</el-radio>
<el-radio value="users">指定用户</el-radio>
</el-radio-group>
</el-form-item>
<!-- TODO @AIuserselect 组件 -->
<el-form-item v-if="targetType === 'users'" label="接收用户" prop="receiverUserIds">
<el-input
v-model="receiverInput"
placeholder="多个用户编号用英文逗号分隔,如 1,1024,2048"
type="textarea"
:rows="2"
/>
<el-form-item
v-if="formData.receiverUserType === 'users'"
label="接收用户"
prop="receiverUserIds"
>
<!-- TODO @芋艿后续换成 userselect 组件 -->
<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>
<template #footer>
@ -56,75 +53,51 @@
</template>
<script lang="ts" setup>
// TODO @AI user form
import * as MessageApi from '@/api/im/manager/channel/message'
import * as MaterialApi from '@/api/im/manager/channel/material'
import type { ImManagerChannelVO } from '@/api/im/manager/channel'
import * as UserApi from '@/api/system/user'
import ChannelSelect from '../list/components/ChannelSelect.vue'
import MaterialSelect from '../material/components/MaterialSelect.vue'
defineOptions({ name: 'ImChannelMessageSendForm' })
const props = defineProps<{ channelList: ImManagerChannelVO[] }>()
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const formLoading = ref(false)
const message = useMessage() //
const dialogVisible = ref(false) //
const formLoading = ref(false) //
const formData = ref({
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 receiverInput = ref('')
const materialList = ref<MaterialApi.ImManagerChannelMaterialVO[]>([])
const userList = ref<UserApi.UserVO[]>([]) //
const formRules = reactive({
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
resetForm()
//
userList.value = await UserApi.getSimpleUserList()
}
defineExpose({ open })
defineExpose({ open }) // open
const emit = defineEmits(['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 emit = defineEmits(['success']) // success
/** 提交表单 */
const submitForm = async () => {
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
try {
await MessageApi.sendManagerChannelMessage({
materialId: formData.value.materialId!,
receiverUserIds
receiverUserIds:
formData.value.receiverUserType === 'users' ? formData.value.receiverUserIds : undefined
})
message.success('推送成功')
dialogVisible.value = false
@ -134,11 +107,14 @@ const submitForm = async () => {
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = { channelId: undefined, materialId: undefined }
targetType.value = 'all'
receiverInput.value = ''
materialList.value = []
formData.value = {
channelId: undefined,
materialId: undefined,
receiverUserType: 'all',
receiverUserIds: []
}
formRef.value?.resetFields()
}
</script>

View File

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

View File

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