✨ feat(im): 增加频道的检查
parent
5ebbbf7499
commit
b6d123ac72
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 返回 void;load{Friends,Groups} 返回是否命中缓存)
|
||||
const [, hasCachedFriends, hasCachedGroups] = await Promise.all([
|
||||
// 1.2 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
/* 全屏文章详情样式(非 scoped;el-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>
|
||||
|
|
@ -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 / avatar;conversationStore 持久化的旧占位被刷掉 */
|
||||
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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 @AI:注释风格,ChannelApi 列表,让 channel/components 抽个组件,复用一下;
|
||||
channelList.value = await ChannelApi.getEnabledChannelList()
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -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 @AI:userselect 组件 -->
|
||||
<el-form-item v-if="targetType === 'users'" label="接收用户" prop="receiverUserIds">
|
||||
<el-input
|
||||
v-model="receiverInput"
|
||||
placeholder="多个用户编号用英文逗号分隔,如 1,1024,2048"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
</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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue