feat: 新增渠道身份菜单、会话成员/标签展示、修复群聊成员 person_type 及重复同步问题

This commit is contained in:
2026-07-13 09:38:03 +08:00
parent 749856c455
commit 31d4a34ebb
16 changed files with 502 additions and 5 deletions

View File

@@ -2,6 +2,10 @@ import client from './client'
export * from './channelSubject'
export * from './channelAccount'
export interface ChannelIdentityQuery {
keyword?: string
}
export function createConversation(data: any) {
return client.post('/api/v1/conversation', data)
}
@@ -25,3 +29,15 @@ export function bindConversationAccount(conversationId: number, data: any) {
export function bindConversationTags(conversationId: number, data: any) {
return client.post(`/api/v1/conversation/${conversationId}/bind-tags`, data)
}
export function listChannelIdentities(params?: ChannelIdentityQuery) {
return client.get('/api/v1/channel-identity/list', { params })
}
export function listConversationParticipants(conversationId: number) {
return client.get(`/api/v1/conversation/${conversationId}/participants`)
}
export function listConversationTags(conversationId: number) {
return client.get(`/api/v1/conversation/${conversationId}/tags`)
}

View File

@@ -27,6 +27,7 @@
<el-menu-item index="/channel/subject">渠道主体</el-menu-item>
<el-menu-item index="/channel/app">渠道应用</el-menu-item>
<el-menu-item index="/channel/account">渠道账号</el-menu-item>
<el-menu-item index="/channel/identity">渠道身份</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/customer">

View File

@@ -4,6 +4,7 @@ import Dashboard from '../views/Dashboard.vue'
import ChannelSubject from '../views/ChannelSubject.vue'
import ChannelApp from '../views/ChannelApp.vue'
import ChannelAccount from '../views/ChannelAccount.vue'
import ChannelIdentity from '../views/ChannelIdentity.vue'
import Person from '../views/Person.vue'
import Conversation from '../views/Conversation.vue'
import Tag from '../views/Tag.vue'
@@ -30,6 +31,7 @@ const routes = [
{ path: 'channel/subject', component: ChannelSubject, meta: { title: '渠道主体' } },
{ path: 'channel/app', component: ChannelApp, meta: { title: '渠道应用' } },
{ path: 'channel/account', component: ChannelAccount, meta: { title: '渠道账号' } },
{ path: 'channel/identity', component: ChannelIdentity, meta: { title: '渠道身份' } },
{ path: 'customer/person', component: Person, meta: { title: '联系人' } },
{ path: 'customer/group', component: Conversation, meta: { title: '群聊' } },
{ path: 'customer/tag', component: Tag, meta: { title: '标签体系' } },

View File

@@ -0,0 +1,112 @@
<template>
<PageCard>
<template #toolbar-left>
<el-input
v-model="searchKeyword"
placeholder="搜索渠道用户ID / 渠道用户名 / 联系人姓名"
clearable
style="width: 320px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</template>
<el-table :data="pagedIdentities" class="sino-table">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道类型" width="110">
<template #default="{ row }">
{{ channelLabel(row.channelType) }}
</template>
</el-table-column>
<el-table-column prop="channelUserId" label="渠道用户ID" show-overflow-tooltip />
<el-table-column prop="channelUserName" label="渠道用户名" show-overflow-tooltip />
<el-table-column prop="subjectName" label="所属主体" show-overflow-tooltip />
<el-table-column prop="personName" label="关联联系人" show-overflow-tooltip />
<el-table-column prop="personType" label="人员类型" width="110">
<template #default="{ row }">
{{ formatPersonType(row.personType) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<template #pagination>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="filteredIdentities.length"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
/>
</template>
</PageCard>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { listChannelIdentities } from '@/api/channel'
import PageCard from '@/components/PageCard.vue'
const identities = ref<any[]>([])
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const filteredIdentities = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase()
if (!kw) return identities.value
return identities.value.filter((item) =>
[item.channelUserId, item.channelUserName, item.personName].some((v) =>
String(v || '').toLowerCase().includes(kw)
)
)
})
const pagedIdentities = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredIdentities.value.slice(start, start + pageSize.value)
})
watch([searchKeyword], () => {
currentPage.value = 1
})
function channelLabel(type: string | undefined) {
const map: Record<string, string> = {
wecom: '企微',
wechat_personal: '个微',
dingtalk: '钉钉',
feishu: '飞书',
ivr: 'IVR',
}
return map[type || ''] || type || '-'
}
function formatPersonType(type: number | undefined) {
if (type === 3) return '客户'
if (type == null) return '-'
return '内部'
}
async function loadIdentities() {
try {
const res: any = await listChannelIdentities({ keyword: searchKeyword.value.trim() })
identities.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadIdentities)
</script>

View File

@@ -151,6 +151,38 @@
</div>
</div>
<el-divider />
<div class="detail-section">
<div class="section-title">群成员</div>
<el-table :data="participants" class="sino-table" size="small" max-height="320">
<el-table-column prop="participantType" label="类型" width="90">
<template #default="{ row }">
{{ formatParticipantType(row.participantType) }}
</template>
</el-table-column>
<el-table-column prop="channelUserId" label="渠道用户ID" show-overflow-tooltip />
<el-table-column prop="personName" label="姓名" show-overflow-tooltip />
<el-table-column prop="personType" label="人员类型" width="100">
<template #default="{ row }">
{{ formatParticipantPersonType(row.personType) }}
</template>
</el-table-column>
</el-table>
</div>
<el-divider />
<div class="detail-section">
<div class="section-title">已绑定标签</div>
<div v-if="boundTags.length" class="tag-list">
<el-tag v-for="t in boundTags" :key="t.id" class="tag-item" size="small">
{{ t.tagName }}
</el-tag>
</div>
<el-empty v-else description="暂无标签" :image-size="60" />
</div>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="danger" @click="handleDelete(currentConversation)">删除</el-button>
@@ -219,6 +251,8 @@ import {
createConversation,
deleteConversation,
listChannelAccounts,
listConversationParticipants,
listConversationTags,
listConversations,
updateConversation,
} from '@/api/channel'
@@ -248,6 +282,8 @@ const currentPage = ref(1)
const pageSize = ref(10)
const drawerVisible = ref(false)
const currentConversation = ref<any>({})
const participants = ref<any[]>([])
const boundTags = ref<any[]>([])
const filteredConversations = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
@@ -305,6 +341,8 @@ function openDrawer(row: any) {
editingId.value = row.id
fillDialogForm(row)
drawerVisible.value = true
loadParticipants(row.id)
loadBoundTags(row.id)
}
const accountMap = computed(() => {
@@ -334,6 +372,45 @@ function formatConversationType(type: number | undefined) {
return type ?? '-'
}
function formatParticipantType(type: number | undefined) {
if (type === 1) return '群主'
return '成员'
}
function formatParticipantPersonType(type: number | undefined) {
if (type === 3) return '客户'
if (type == null) return '-'
return '内部'
}
async function loadParticipants(conversationId: number | undefined) {
if (!conversationId) {
participants.value = []
return
}
try {
const res: any = await listConversationParticipants(conversationId)
participants.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
participants.value = []
}
}
async function loadBoundTags(conversationId: number | undefined) {
if (!conversationId) {
boundTags.value = []
return
}
try {
const res: any = await listConversationTags(conversationId)
boundTags.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
boundTags.value = []
}
}
async function loadConversations() {
try {
const res: any = await listConversations()
@@ -504,4 +581,18 @@ onMounted(() => {
display: flex;
gap: 16px;
}
.detail-section {
margin-bottom: 8px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-item {
margin-right: 0;
}
</style>

View File

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/channelaccount.ts","./src/api/channelsubject.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/dashboard.ts","./src/api/flow.ts","./src/api/inbound.ts","./src/api/inboundflow.ts","./src/api/inboundwebhook.ts","./src/api/ivrflow.ts","./src/api/send.ts","./src/api/sendpolicy.ts","./src/api/sendrecord.ts","./src/api/synctask.ts","./src/router/index.ts","./src/stores/auth.ts","./src/app.vue","./src/components/layout.vue","./src/components/pagecard.vue","./src/views/channelaccount.vue","./src/views/channelapp.vue","./src/views/channelsubject.vue","./src/views/conversation.vue","./src/views/dashboard.vue","./src/views/flowtrace.vue","./src/views/inboundflow.vue","./src/views/inboundwebhook.vue","./src/views/ivrflow.vue","./src/views/login.vue","./src/views/messagetest.vue","./src/views/messages.vue","./src/views/person.vue","./src/views/sendpolicy.vue","./src/views/sendrecord.vue","./src/views/tag.vue","./src/views/tenantmanagement.vue","./src/views/usermanagement.vue"],"version":"6.0.3"}
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/channelaccount.ts","./src/api/channelsubject.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/dashboard.ts","./src/api/flow.ts","./src/api/inbound.ts","./src/api/inboundflow.ts","./src/api/inboundwebhook.ts","./src/api/ivrflow.ts","./src/api/send.ts","./src/api/sendpolicy.ts","./src/api/sendrecord.ts","./src/api/synctask.ts","./src/router/index.ts","./src/stores/auth.ts","./src/app.vue","./src/components/layout.vue","./src/components/pagecard.vue","./src/views/channelaccount.vue","./src/views/channelapp.vue","./src/views/channelidentity.vue","./src/views/channelsubject.vue","./src/views/conversation.vue","./src/views/dashboard.vue","./src/views/flowtrace.vue","./src/views/inboundflow.vue","./src/views/inboundwebhook.vue","./src/views/ivrflow.vue","./src/views/login.vue","./src/views/messagetest.vue","./src/views/messages.vue","./src/views/person.vue","./src/views/sendpolicy.vue","./src/views/sendrecord.vue","./src/views/tag.vue","./src/views/tenantmanagement.vue","./src/views/usermanagement.vue"],"version":"6.0.3"}