feat(im): 继续优化频道的各种代码(v3)

im
YunaiV 2026-05-19 22:06:38 +08:00
parent 2442a01e48
commit 94e5fc00ac
6 changed files with 168 additions and 70 deletions

View File

@ -18,6 +18,13 @@ export const getManagerChannelMaterialPage = (params: PageParam) => {
return request.get({ url: '/im/manager/channel-material/page', params }) return request.get({ url: '/im/manager/channel-material/page', params })
} }
// 获得指定频道下的素材精简列表
export const getSimpleManagerChannelMaterialList = (channelId: number) => {
return request.get({
url: '/im/manager/channel-material/simple-list?channelId=' + channelId
})
}
// 获得素材详情 // 获得素材详情
export const getManagerChannelMaterial = (id: number) => { export const getManagerChannelMaterial = (id: number) => {
return request.get({ url: '/im/manager/channel-material/get?id=' + id }) return request.get({ url: '/im/manager/channel-material/get?id=' + id })

View File

@ -1,11 +1,26 @@
<template> <template>
<div class="material-card" @click="onClick"> <!-- 公众号会话内大卡片封面 + 标题 + 摘要 -->
<div v-if="isChannelView" class="material-card" @click="onClick">
<div class="title">{{ payload.title || '(无标题)' }}</div> <div class="title">{{ payload.title || '(无标题)' }}</div>
<img v-if="payload.coverUrl" class="cover" :src="payload.coverUrl" /> <img v-if="payload.coverUrl" class="cover" :src="payload.coverUrl" />
<div v-if="payload.summary" class="summary">{{ payload.summary }}</div> <div v-if="payload.summary" class="summary">{{ payload.summary }}</div>
<span class="link">{{ payload.url ? '外链' : '查看详情' }}</span> <span class="link">{{ payload.url ? '外链' : '查看详情' }}</span>
</div> </div>
<!-- 私聊 / 群聊里被转发的素材紧凑卡片标题左 + 小封面右 + 底部频道标识 -->
<!-- TODO @AI转发后的消息无法点击打开 -->
<div v-else class="material-card-forward" @click="onClick">
<div class="forward-body">
<div class="forward-title">{{ payload.title || '(无标题)' }}</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>
</div>
</div>
<!-- TODO @ai需要注释下 -->
<Dialog <Dialog
v-model="detailVisible" v-model="detailVisible"
:title="payload.title || '详情'" :title="payload.title || '详情'"
@ -23,8 +38,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { PropType } from 'vue' import type { PropType } from '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 { ImConversationType } from '@/views/im/utils/constants'
interface MessageInfo { interface MessageInfo {
materialId?: number materialId?: number
@ -35,6 +53,13 @@ const props = defineProps({
message: { type: Object as PropType<MessageInfo>, required: true } message: { type: Object as PropType<MessageInfo>, required: true }
}) })
const conversationStore = useConversationStore()
/** 当前是否在公众号 / 频道会话里:决定走大卡片还是紧凑转发卡片 */
const isChannelView = computed(
() => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** 反序列化 content JSON 为 payload 对象 */ /** 反序列化 content JSON 为 payload 对象 */
const payload = computed<MaterialMessage>( const payload = computed<MaterialMessage>(
() => parseMessage<MaterialMessage>(props.message.content) ?? {} () => parseMessage<MaterialMessage>(props.message.content) ?? {}
@ -69,6 +94,8 @@ const onClick = async () => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 公众号会话内大卡片:占父容器全宽,封面 + 标题 + 摘要纵向铺开 */
/** TODO @AI有没可能 unocss 尽量替代掉; */
.material-card { .material-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -124,6 +151,62 @@ const onClick = async () => {
} }
} }
/* 私聊 / 群聊转发紧凑卡片:标题左 + 小封面右 + 底部频道标识 */
.material-card-forward {
display: flex;
flex-direction: column;
width: 280px;
padding: 10px 12px 8px;
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);
}
.forward-body {
display: flex;
gap: 10px;
align-items: flex-start;
.forward-title {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: var(--el-text-color-primary);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.forward-cover {
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;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
}
.material-detail-body { .material-detail-body {
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;

View File

@ -64,7 +64,13 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="编码" align="center" prop="code" width="160" show-overflow-tooltip /> <el-table-column label="编码" align="center" prop="code" width="160" show-overflow-tooltip />
<el-table-column label="名称" align="center" prop="name" min-width="120" show-overflow-tooltip /> <el-table-column
label="名称"
align="center"
prop="name"
min-width="120"
show-overflow-tooltip
/>
<el-table-column label="排序" align="center" prop="sort" width="80" /> <el-table-column label="排序" align="center" prop="sort" width="80" />
<el-table-column label="状态" align="center" prop="status" width="80"> <el-table-column label="状态" align="center" prop="status" width="80">
<template #default="scope"> <template #default="scope">
@ -111,7 +117,6 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
// TOOD @AI system user index
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import * as ChannelApi from '@/api/im/manager/channel' import * as ChannelApi from '@/api/im/manager/channel'
@ -119,12 +124,12 @@ import ChannelForm from './ChannelForm.vue'
defineOptions({ name: 'ImChannel' }) defineOptions({ name: 'ImChannel' })
const message = useMessage() const message = useMessage() //
const { t } = useI18n() const { t } = useI18n() //
const loading = ref(true) const loading = ref(true) //
const total = ref(0) const total = ref(0) //
const list = ref<ChannelApi.ImManagerChannelVO[]>([]) const list = ref<ChannelApi.ImManagerChannelVO[]>([]) //
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
@ -132,7 +137,7 @@ const queryParams = reactive({
name: undefined as string | undefined, name: undefined as string | undefined,
status: undefined as number | undefined status: undefined as number | undefined
}) })
const queryFormRef = ref() const queryFormRef = ref() //
/** 查询频道分页 */ /** 查询频道分页 */
const getList = async () => { const getList = async () => {
@ -176,6 +181,7 @@ const handleDelete = async (id: number) => {
await getList() await getList()
} }
/** 初始化 **/
onMounted(() => { onMounted(() => {
getList() getList()
}) })

View File

@ -8,10 +8,14 @@
v-loading="formLoading" v-loading="formLoading"
> >
<el-form-item label="所属频道" prop="channelId"> <el-form-item label="所属频道" prop="channelId">
<!-- TODO @AI使用 channelselect 组件 --> <ChannelSelect v-model="formData.channelId" placeholder="请选择频道" />
<el-select v-model="formData.channelId" placeholder="请选择频道" class="!w-full"> </el-form-item>
<el-option v-for="c in props.channelList" :key="c.id" :label="c.name" :value="c.id" /> <!-- TODO @AI是不是内容类型在考虑优化下1富文本2外链更简介注意需要插入到字典里 -->
</el-select> <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-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 />
@ -29,14 +33,8 @@
show-word-limit show-word-limit
/> />
</el-form-item> </el-form-item>
<!-- TODO @AI需要增加 type --> <!-- 内容类型为站内富文本时展示 content 富文本输入外链时展示 url 输入 -->
<el-form-item label="跳转链接" prop="url"> <el-form-item v-if="formData.type === 1" label="正文" prop="content">
<el-input
v-model="formData.url"
placeholder="为空走站内详情页渲染 content非空则跳此链接"
/>
</el-form-item>
<el-form-item label="正文" prop="content">
<el-input <el-input
v-model="formData.content" v-model="formData.content"
placeholder="富文本 HTML" placeholder="富文本 HTML"
@ -44,6 +42,9 @@
:rows="8" :rows="8"
/> />
</el-form-item> </el-form-item>
<el-form-item v-else label="跳转链接" prop="url">
<el-input v-model="formData.url" placeholder="https://example.com/..." />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button> <el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
@ -53,26 +54,22 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
// TODO @AI system user form
import * as MaterialApi from '@/api/im/manager/channel/material' import * as MaterialApi from '@/api/im/manager/channel/material'
import type { ImManagerChannelVO } from '@/api/im/manager/channel' import ChannelSelect from '../list/components/ChannelSelect.vue'
defineOptions({ name: 'ImChannelMaterialForm' }) defineOptions({ name: 'ImChannelMaterialForm' })
const props = defineProps<{ channelList: ImManagerChannelVO[] }>() const { t } = useI18n() //
const message = useMessage() //
const { t } = useI18n() const dialogVisible = ref(false) //
const message = useMessage() const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const dialogVisible = ref(false) const formType = ref('') // create - update -
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const formData = ref({ const formData = ref({
id: undefined as number | undefined, id: undefined as number | undefined,
channelId: undefined as number | undefined, channelId: undefined as number | undefined,
// TODO @AI type 1 2 type: 1, // 1 2 ImChannelMaterialTypeEnum
type: 125, // MATERIAL
title: '', title: '',
coverUrl: '', coverUrl: '',
summary: '', summary: '',
@ -81,15 +78,18 @@ const formData = ref({
}) })
const formRules = reactive({ const formRules = reactive({
channelId: [{ required: true, message: '所属频道不能为空', trigger: 'change' }], channelId: [{ required: true, message: '所属频道不能为空', trigger: 'change' }],
type: [{ required: true, message: '内容类型不能为空', trigger: 'change' }],
title: [{ required: true, message: '标题不能为空', trigger: 'blur' }] title: [{ required: true, message: '标题不能为空', trigger: 'blur' }]
}) })
const formRef = ref() const formRef = ref() // Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => { const open = async (type: string, id?: number) => {
dialogVisible.value = true dialogVisible.value = true
dialogTitle.value = t('action.' + type) dialogTitle.value = t('action.' + type)
formType.value = type formType.value = type
resetForm() resetForm()
//
if (id) { if (id) {
formLoading.value = true formLoading.value = true
try { try {
@ -99,10 +99,11 @@ const open = async (type: string, id?: number) => {
} }
} }
} }
defineExpose({ open }) defineExpose({ open }) // open
const emit = defineEmits(['success']) const emit = defineEmits(['success']) // success
/** 提交表单 */
const submitForm = async () => { const submitForm = async () => {
await formRef.value.validate() await formRef.value.validate()
formLoading.value = true formLoading.value = true
@ -122,11 +123,12 @@ const submitForm = async () => {
} }
} }
/** 重置表单 */
const resetForm = () => { const resetForm = () => {
formData.value = { formData.value = {
id: undefined, id: undefined,
channelId: undefined, channelId: undefined,
type: 125, type: 1,
title: '', title: '',
coverUrl: '', coverUrl: '',
summary: '', summary: '',

View File

@ -8,9 +8,7 @@
label-width="68px" label-width="68px"
> >
<el-form-item label="频道" prop="channelId"> <el-form-item label="频道" prop="channelId">
<el-select v-model="queryParams.channelId" placeholder="全部" clearable class="!w-200px"> <ChannelSelect v-model="queryParams.channelId" placeholder="全部" clearable class="!w-200px" />
<el-option v-for="c in channelList" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="标题" prop="title"> <el-form-item label="标题" prop="title">
<el-input <el-input
@ -108,33 +106,32 @@
/> />
</ContentWrap> </ContentWrap>
<ChannelMaterialForm ref="formRef" :channel-list="channelList" @success="getList" /> <ChannelMaterialForm ref="formRef" @success="getList" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
// TOOD @AI system user index
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 * as ChannelApi from '@/api/im/manager/channel' import ChannelSelect from '../list/components/ChannelSelect.vue'
import ChannelMaterialForm from './ChannelMaterialForm.vue' import ChannelMaterialForm from './ChannelMaterialForm.vue'
defineOptions({ name: 'ImChannelMaterial' }) defineOptions({ name: 'ImChannelMaterial' })
const message = useMessage() const message = useMessage() //
const { t } = useI18n() const { t } = useI18n() //
const loading = ref(true) const loading = ref(true) //
const total = ref(0) const total = ref(0) //
const list = ref<MaterialApi.ImManagerChannelMaterialVO[]>([]) const list = ref<MaterialApi.ImManagerChannelMaterialVO[]>([]) //
const channelList = ref<ChannelApi.ImManagerChannelVO[]>([])
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
channelId: undefined as number | undefined, channelId: undefined as number | undefined,
title: undefined as string | undefined title: undefined as string | undefined
}) })
const queryFormRef = ref() const queryFormRef = ref() //
/** 查询素材分页 */
const getList = async () => { const getList = async () => {
loading.value = true loading.value = true
try { try {
@ -146,21 +143,25 @@ 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() // 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()
@ -172,9 +173,8 @@ const handleDelete = async (id: number) => {
await getList() await getList()
} }
onMounted(async () => { /** 初始化 */
// TOOD @AIChannelApi channel/components onMounted(() => {
channelList.value = await ChannelApi.getEnabledChannelList()
getList() getList()
}) })
</script> </script>

View File

@ -8,9 +8,7 @@
label-width="68px" label-width="68px"
> >
<el-form-item label="频道" prop="channelId"> <el-form-item label="频道" prop="channelId">
<el-select v-model="queryParams.channelId" placeholder="全部" clearable class="!w-200px"> <ChannelSelect v-model="queryParams.channelId" placeholder="全部" clearable class="!w-200px" />
<el-option v-for="c in channelList" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="发送时间" prop="sendTime"> <el-form-item label="发送时间" prop="sendTime">
<el-date-picker <el-date-picker
@ -88,33 +86,32 @@
/> />
</ContentWrap> </ContentWrap>
<ChannelMessageSendForm ref="sendFormRef" :channel-list="channelList" @success="getList" /> <ChannelMessageSendForm ref="sendFormRef" @success="getList" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
// TODO @AI system user index
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import * as MessageApi from '@/api/im/manager/channel/message' import * as MessageApi from '@/api/im/manager/channel/message'
import * as ChannelApi from '@/api/im/manager/channel' import ChannelSelect from '../list/components/ChannelSelect.vue'
import ChannelMessageSendForm from './ChannelMessageSendForm.vue' import ChannelMessageSendForm from './ChannelMessageSendForm.vue'
defineOptions({ name: 'ImChannelMessage' }) defineOptions({ name: 'ImChannelMessage' })
const message = useMessage() const message = useMessage() //
const { t } = useI18n() const { t } = useI18n() //
const loading = ref(true) const loading = ref(true) //
const total = ref(0) const total = ref(0) //
const list = ref<MessageApi.ImManagerChannelMessageVO[]>([]) const list = ref<MessageApi.ImManagerChannelMessageVO[]>([]) //
const channelList = ref<ChannelApi.ImManagerChannelVO[]>([])
const queryParams = reactive({ const queryParams = reactive({
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
channelId: undefined as number | undefined, channelId: undefined as number | undefined,
sendTime: [] as string[] sendTime: [] as string[]
}) })
const queryFormRef = ref() const queryFormRef = ref() //
/** 查询消息分页 */
const getList = async () => { const getList = async () => {
loading.value = true loading.value = true
try { try {
@ -126,21 +123,25 @@ 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() /** 打开「立即推送」弹窗 */
const sendFormRef = ref() // 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()
@ -152,8 +153,7 @@ const handleDelete = async (id: number) => {
await getList() await getList()
} }
onMounted(async () => { onMounted(() => {
channelList.value = await ChannelApi.getEnabledChannelList()
getList() getList()
}) })
</script> </script>