✨ feat(im): 继续优化频道的各种代码(v4)优化卡片样式
parent
94e5fc00ac
commit
9a36cfe933
|
|
@ -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=外链
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 视图 */
|
||||
|
|
|
|||
|
|
@ -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' }]
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
|
|
|
|||
|
|
@ -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 @AI:userselect 组件 -->
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 构造 ====================
|
||||
|
|
|
|||
Loading…
Reference in New Issue