✨ 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_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=外链
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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<{
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 视图 */
|
||||||
|
|
|
||||||
|
|
@ -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' }]
|
||||||
})
|
})
|
||||||
|
|
@ -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 = () => {
|
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()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</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 {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 初始化 */
|
/** 初始化 */
|
||||||
|
|
|
||||||
|
|
@ -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 @AI:userselect 组件 -->
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 构造 ====================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue