feat(im): 增加频道的检查

im
YunaiV 2026-05-19 14:18:08 +08:00
parent 5ebbbf7499
commit b6d123ac72
11 changed files with 1167 additions and 25 deletions

View File

@ -0,0 +1,18 @@
import request from '@/config/axios'
// 用户端能看到的频道素材详情
export interface ImChannelMaterialRespVO {
id: number
channelId: number
type: number
title: string
coverUrl?: string
summary?: string
content?: string
url?: string
}
// 获取频道素材详情;用于客户端点击图文卡片渲染详情页
export const getChannelMaterial = (id: number) => {
return request.get<ImChannelMaterialRespVO>({ url: '/im/channel/material/get?id=' + id })
}

View File

@ -9,18 +9,6 @@ export interface ImChannelMessageRespVO {
sendTime: string
}
// 用户端能看到的频道素材详情
export interface ImChannelMaterialRespVO {
id: number
channelId: number
type: number
title: string
coverUrl?: string
summary?: string
content?: string
url?: string
}
// 拉取当前用户应收的频道消息(离线增量);按 minId 游标分页
export const pullChannelMessages = (params: { minId: number; size?: number }) => {
return request.get<ImChannelMessageRespVO[]>({
@ -28,9 +16,3 @@ export const pullChannelMessages = (params: { minId: number; size?: number }) =>
params
})
}
// 获取频道素材详情;用于客户端点击图文卡片渲染详情页
// TODO @AI这个地址也要改把。
export const getChannelMaterial = (id: number) => {
return request.get<ImChannelMaterialRespVO>({ url: '/im/channel/material/get?id=' + id })
}

View File

@ -81,12 +81,13 @@ onMounted(async () => {
// 1.1 loading=true saveConversations + WebSocket connect pullOnce maxId pull 线
conversationStore.loading = true
try {
// 1.2 store IDB loadConversations / loadDrafts voidload{Friends,Groups}
const [, hasCachedFriends, hasCachedGroups] = await Promise.all([
// 1.2 store IDB loadConversations / loadDrafts voidload{Friends,Groups,Channels}
const [, hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([
conversationStore.loadConversations(),
friendStore.loadFriends(),
groupStore.loadGroups(),
draftStore.loadDrafts()
draftStore.loadDrafts(),
channelStore.loadChannels()
])
// 2.1 IDB pullOnce
@ -103,10 +104,11 @@ onMounted(async () => {
} else {
requiredFetches.push(groupStore.fetchGroups())
}
// TODO @AI// IDB pull 2.1 2.2
// IDB pull
void channelStore.fetchChannels().catch((e) => console.warn('[IM] 拉取频道列表失败', e))
// 2.3 TODO @AI
if (hasCachedChannels) {
void channelStore.fetchChannels().catch((e) => console.warn('[IM] 后台刷频道列表失败', e))
} else {
requiredFetches.push(channelStore.fetchChannels())
}
if (requiredFetches.length > 0) {
await Promise.all(requiredFetches)
}

View File

@ -0,0 +1,243 @@
<template>
<!-- TODO @AI要不使用 model 组件弹窗用户允许手动弹窗 -->
<div 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>
</div>
<!-- 全屏文章详情仿微信公众号文章打开 -->
<el-dialog
v-model="detailVisible"
:show-close="false"
fullscreen
append-to-body
destroy-on-close
class="material-detail-dialog"
>
<template #header>
<div class="detail-header">
<button class="back-btn" @click="detailVisible = false">
<Icon icon="ant-design:close-outlined" :size="18" />
</button>
<span class="detail-title">{{ payload.title || '详情' }}</span>
<span class="placeholder"></span>
</div>
</template>
<div v-loading="detailLoading" class="detail-body">
<div class="article-title">{{ payload.title || '' }}</div>
<div v-if="detailHtml" class="article-content" v-html="detailHtml"></div>
<div v-else-if="!detailLoading" class="article-empty">暂无正文</div>
</div>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { ElDialog } from 'element-plus'
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'
interface MessageInfo {
materialId?: number
content: string
}
const props = defineProps({
message: { type: Object as PropType<MessageInfo>, required: true }
})
/** 反序列化 content JSON 为 payload 对象 */
const payload = computed<MaterialMessage>(
() => parseMessage<MaterialMessage>(props.message.content) ?? {}
)
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailHtml = ref('')
/** 点击行为url 非空跳外链;为空则按 materialId 拉富文本正文,全屏 dialog 渲染 */
const onClick = async () => {
if (payload.value.url) {
window.open(payload.value.url, '_blank')
return
}
if (!props.message.materialId) {
return
}
detailVisible.value = true
detailLoading.value = true
detailHtml.value = ''
try {
const material = await getChannelMaterial(props.message.materialId)
detailHtml.value = material?.content ?? ''
} catch (e) {
console.error('[Material] 拉取正文失败', e)
} finally {
detailLoading.value = false
}
}
</script>
<style scoped lang="scss">
.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;
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);
}
}
</style>
<style lang="scss">
/* 全屏文章详情样式(非 scopedel-dialog 全屏后 header / body 在 teleport 子树) */
.material-detail-dialog {
.el-dialog__header {
margin: 0;
padding: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-dialog__body {
padding: 0;
}
.detail-header {
display: flex;
align-items: center;
height: 52px;
padding: 0 12px;
background: var(--el-bg-color);
.back-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--el-text-color-primary);
&:hover {
background: var(--el-fill-color-light);
}
}
.detail-title {
flex: 1;
text-align: center;
font-size: 15px;
font-weight: 500;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
width: 36px;
}
}
.detail-body {
max-width: 720px;
margin: 0 auto;
padding: 24px 20px 80px;
min-height: calc(100vh - 52px);
.article-title {
font-size: 22px;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
margin-bottom: 20px;
}
.article-content {
font-size: 15px;
line-height: 1.75;
color: var(--el-text-color-primary);
:deep(img),
img {
max-width: 100%;
height: auto;
}
:deep(p),
p {
margin: 12px 0;
}
:deep(h1),
:deep(h2),
:deep(h3),
h1,
h2,
h3 {
margin: 20px 0 12px;
font-weight: 600;
}
}
.article-empty {
text-align: center;
color: var(--el-text-color-secondary);
margin-top: 80px;
}
}
}
</style>

View File

@ -0,0 +1,101 @@
import { defineStore, acceptHMRUpdate } from 'pinia'
import { store } from '@/store'
import { getEnabledChannelList, type ImManagerChannelVO } from '@/api/im/manager/channel'
import { useConversationStore } from './conversationStore'
import { ImConversationType } from '../../utils/constants'
import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
/**
* IM Store
*
* id / code / name / avatar
* / + 1
*/
export const useChannelStore = defineStore('imChannelStore', {
state: () => ({
channels: [] as ImManagerChannelVO[],
loaded: false
}),
getters: {
getChannel(state): (id: number) => ImManagerChannelVO | undefined {
return (id: number) => state.channels.find((c) => c.id === id)
}
},
actions: {
// ==================== 本地缓存 ====================
/** 从 IDB 恢复频道列表;命中返回 true 让首屏立刻有真实名 / 头像 */
async loadChannels(): Promise<boolean> {
const userId = getCurrentUserId()
if (!userId) {
return false
}
try {
const cached = await imStorage.getItem<ImManagerChannelVO[]>(StorageKeys.channels(userId))
if (!cached || cached.length === 0) {
return false
}
this.channels = cached
return true
} catch (e) {
console.warn('[IM channelStore] 本地频道缓存读取失败', e)
return false
}
},
/** 整桶持久化频道列表(量级小,不维护增量) */
saveChannels(): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
setQuietly(StorageKeys.channels(userId), this.channels, '[IM channelStore] 本地频道缓存写入失败')
},
// ==================== 远端拉取 ====================
/** 拉取启用的频道精简列表;成功后回填会话列表已有的频道 name / avatar覆盖 IDB 旧占位 */
async fetchChannels(force = false) {
if (this.loaded && !force) {
return
}
try {
this.channels = (await getEnabledChannelList()) || []
this.loaded = true
this.syncConversationMetadata()
this.saveChannels()
} catch (e) {
console.warn('[IM channelStore] fetchChannels 失败', e)
}
},
/** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatarconversationStore 持久化的旧占位被刷掉 */
syncConversationMetadata() {
const conversationStore = useConversationStore()
const indexed = new Map(this.channels.map((c) => [c.id, c]))
conversationStore.conversations.forEach((conversation) => {
if (conversation.type !== ImConversationType.CHANNEL) {
return
}
const channel = indexed.get(conversation.targetId)
if (!channel) {
return
}
if (channel.name) {
conversation.name = channel.name
}
if (channel.avatar) {
conversation.avatar = channel.avatar
}
})
}
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot))
}
export const useImChannelStore = () => useChannelStore(store)

View File

@ -0,0 +1,129 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="520">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="频道编码" prop="code">
<el-input
v-model="formData.code"
placeholder="如 system_notice"
:disabled="formType === 'update'"
/>
</el-form-item>
<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" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" controls-position="right" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as ChannelApi from '@/api/im/manager/channel'
import { CommonStatusEnum } from '@/utils/constants'
defineOptions({ name: 'ImChannelForm' })
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const formData = ref({
id: undefined as number | undefined,
code: '',
name: '',
avatar: '',
sort: 0,
status: CommonStatusEnum.ENABLE
})
const formRules = reactive({
code: [
{ required: true, message: '频道编码不能为空', trigger: 'blur' },
{ pattern: /^[a-z][a-z0-9_]*$/, message: '只能由小写字母 / 数字 / 下划线组成,且以字母开头', trigger: 'blur' }
],
name: [{ required: true, message: '频道名称不能为空', trigger: 'blur' }],
sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
})
const formRef = ref()
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
if (id) {
formLoading.value = true
try {
formData.value = await ChannelApi.getManagerChannel(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open })
const emit = defineEmits(['success'])
/** 提交 */
const submitForm = async () => {
await formRef.value.validate()
formLoading.value = true
try {
const data = formData.value as ChannelApi.ImManagerChannelVO
if (formType.value === 'create') {
await ChannelApi.createManagerChannel(data)
message.success(t('common.createSuccess'))
} else {
await ChannelApi.updateManagerChannel(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
const resetForm = () => {
formData.value = {
id: undefined,
code: '',
name: '',
avatar: '',
sort: 0,
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
</script>

View File

@ -0,0 +1,182 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="编码" prop="code">
<el-input
v-model="queryParams.code"
placeholder="频道业务码"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="频道名称"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择" clearable class="!w-200px">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['im:manager:channel:create']"
>
<Icon icon="ep:plus" class="mr-5px" />新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="id" width="80" />
<el-table-column label="头像" align="center" prop="avatar" width="70">
<template #default="scope">
<el-image
v-if="scope.row.avatar"
:src="scope.row.avatar"
class="w-32px h-32px rounded"
fit="contain"
/>
</template>
</el-table-column>
<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="sort" width="80" />
<el-table-column label="状态" align="center" prop="status" width="80">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
width="170"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center" width="160" fixed="right">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['im:manager:channel:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['im:manager:channel:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<ChannelForm ref="formRef" @success="getList" />
</template>
<script lang="ts" setup>
// TOOD @AI system user index
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import * as ChannelApi from '@/api/im/manager/channel'
import ChannelForm from './ChannelForm.vue'
defineOptions({ name: 'ImChannel' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const total = ref(0)
const list = ref<ChannelApi.ImManagerChannelVO[]>([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
code: undefined as string | undefined,
name: undefined as string | undefined,
status: undefined as number | undefined
})
const queryFormRef = ref()
/** 查询频道分页 */
const getList = async () => {
loading.value = true
try {
const data = await ChannelApi.getManagerChannelPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索 */
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()
}
onMounted(() => {
getList()
})
</script>

View File

@ -0,0 +1,180 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="频道" prop="channelId">
<el-select 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 label="标题" prop="title">
<el-input
v-model="queryParams.title"
placeholder="标题关键字"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['im:manager:channel-material:create']"
>
<Icon icon="ep:plus" class="mr-5px" />新增素材
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="id" width="80" />
<el-table-column label="封面" align="center" prop="coverUrl" width="80">
<template #default="scope">
<el-image
v-if="scope.row.coverUrl"
:src="scope.row.coverUrl"
class="w-40px h-40px rounded"
fit="cover"
/>
</template>
</el-table-column>
<el-table-column label="频道" align="center" prop="channelName" width="120" />
<el-table-column
label="标题"
align="left"
prop="title"
min-width="180"
show-overflow-tooltip
/>
<el-table-column
label="摘要"
align="left"
prop="summary"
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"
prop="createTime"
width="170"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center" width="160" fixed="right">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['im:manager:channel-material:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['im:manager:channel-material:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<ChannelMaterialForm ref="formRef" :channel-list="channelList" @success="getList" />
</template>
<script lang="ts" setup>
// TOOD @AI system user index
import { dateFormatter } from '@/utils/formatTime'
import * as MaterialApi from '@/api/im/manager/channel/material'
import * as ChannelApi from '@/api/im/manager/channel'
import ChannelMaterialForm from './ChannelMaterialForm.vue'
defineOptions({ name: 'ImChannelMaterial' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const total = ref(0)
const list = ref<MaterialApi.ImManagerChannelMaterialVO[]>([])
const channelList = ref<ChannelApi.ImManagerChannelVO[]>([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
channelId: undefined as number | undefined,
title: undefined as string | undefined
})
const queryFormRef = ref()
const getList = async () => {
loading.value = true
try {
const data = await MaterialApi.getManagerChannelMaterialPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
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 MaterialApi.deleteManagerChannelMaterial(id)
message.success(t('common.delSuccess'))
await getList()
}
onMounted(async () => {
// TOOD @AIChannelApi channel/components
channelList.value = await ChannelApi.getEnabledChannelList()
getList()
})
</script>

View File

@ -0,0 +1,144 @@
<template>
<Dialog title="立即推送频道消息" v-model="dialogVisible" width="640">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="110px"
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>
</el-form-item>
<el-form-item label="素材" prop="materialId">
<el-select
v-model="formData.materialId"
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 value="all">全员</el-radio>
<el-radio value="users">指定用户</el-radio>
</el-radio-group>
</el-form-item>
<!-- TODO @AIuserselect 组件 -->
<el-form-item v-if="targetType === 'users'" label="接收用户" prop="receiverUserIds">
<el-input
v-model="receiverInput"
placeholder="多个用户编号用英文逗号分隔,如 1,1024,2048"
type="textarea"
:rows="2"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading">确认推送</el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</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'
defineOptions({ name: 'ImChannelMessageSendForm' })
const props = defineProps<{ channelList: ImManagerChannelVO[] }>()
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const formLoading = ref(false)
const formData = ref({
channelId: undefined as number | undefined,
materialId: undefined as number | undefined
})
const targetType = ref<'all' | 'users'>('all')
const receiverInput = ref('')
const materialList = ref<MaterialApi.ImManagerChannelMaterialVO[]>([])
const formRules = reactive({
channelId: [{ required: true, message: '请选择频道', trigger: 'change' }],
materialId: [{ required: true, message: '请选择素材', trigger: 'change' }]
})
const formRef = ref()
const open = () => {
dialogVisible.value = true
resetForm()
}
defineExpose({ 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 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
})
message.success('推送成功')
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
const resetForm = () => {
formData.value = { channelId: undefined, materialId: undefined }
targetType.value = 'all'
receiverInput.value = ''
materialList.value = []
formRef.value?.resetFields()
}
</script>

View File

@ -0,0 +1,159 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="频道" prop="channelId">
<el-select 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 label="发送时间" prop="sendTime">
<el-date-picker
v-model="queryParams.sendTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始"
end-placeholder="结束"
class="!w-300px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button
type="primary"
plain
@click="openSendForm"
v-hasPermi="['im:manager:channel-message:send']"
>
<Icon icon="ep:promotion" class="mr-5px" />立即推送
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="id" width="80" />
<el-table-column label="频道" align="center" prop="channelName" width="120" />
<el-table-column
label="素材标题"
align="left"
prop="materialTitle"
min-width="180"
show-overflow-tooltip
/>
<el-table-column label="接收人" align="center" prop="receiverUserIds" width="120">
<template #default="scope">
<el-tag
v-if="!scope.row.receiverUserIds || scope.row.receiverUserIds.length === 0"
type="warning"
size="small"
>
全员
</el-tag>
<span v-else>{{ scope.row.receiverUserIds.length }} </span>
</template>
</el-table-column>
<el-table-column
label="发送时间"
align="center"
prop="sendTime"
width="170"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center" width="100" fixed="right">
<template #default="scope">
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['im:manager:channel-message:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<ChannelMessageSendForm ref="sendFormRef" :channel-list="channelList" @success="getList" />
</template>
<script lang="ts" setup>
// TODO @AI system user index
import { dateFormatter } from '@/utils/formatTime'
import * as MessageApi from '@/api/im/manager/channel/message'
import * as ChannelApi from '@/api/im/manager/channel'
import ChannelMessageSendForm from './ChannelMessageSendForm.vue'
defineOptions({ name: 'ImChannelMessage' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const total = ref(0)
const list = ref<MessageApi.ImManagerChannelMessageVO[]>([])
const channelList = ref<ChannelApi.ImManagerChannelVO[]>([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
channelId: undefined as number | undefined,
sendTime: [] as string[]
})
const queryFormRef = ref()
const getList = async () => {
loading.value = true
try {
const data = await MessageApi.getManagerChannelMessagePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
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()
}
onMounted(async () => {
channelList.value = await ChannelApi.getEnabledChannelList()
getList()
})
</script>

View File

@ -56,6 +56,8 @@ export const StorageKeys = {
friends: (userId: number | string) => `friends:${userId}`,
/** 群列表整桶(不含 members剥离到独立 key保证整桶写不带成员爆量 */
groups: (userId: number | string) => `groups:${userId}`,
/** 频道列表整桶;频道量级很小,整桶整写够用 */
channels: (userId: number | string) => `channels:${userId}`,
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
groupMembers: (userId: number | string, groupId: number) =>
`groupMembers:${userId}:${groupId}`,