Files
yunke/yunkes/src/views/GroupList.vue
T

517 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getGroups } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport'
import { buildGroupSections, readField } from '@/utils/groupFields'
import { fromTypeLabel } from '@/utils/customerFields'
/**
* 群列表 — channel-tabbed view mirroring 云客「沟通」板块的群列表.
*
* 微信群 tab 对齐 云客 /cms/wechat/wechatlist/wechatdetail/2/{wxId}
* (7 列: 群信息/数据来源/群主信息/群成员数量/群成员好友数量/消息数量/操作).
* WhatsApp群 tab 对齐 云客 /cms/wechat/waBusiness/friendlist 的群列表 tab
* (8 列: 序号/员工信息/员工WhatsApp/群信息/群消息数量/添加时间/最新消息时间/操作).
* 操作列「查看聊天记录」→ group-chat;row-click 开全字段 drawer。
*
* ⚠️ smoke.db 暂无群数据(chat_group 0 行)+ WA 群抓取缺失 → 表格空表(列结构对)。
* 部分 wechat 群字段(fromType/群主昵称/群主wxId/群成员好友数量)与全部 WA 群字段为
* 从云客采集推断,经 readField raw_json fallback 读取;待 smoke.db 有群数据核对。
*/
type TabKey = Channel
interface TabState {
items: Group[]
total: number
page: number
pageSize: number
loading: boolean
errorMsg: string | null
loaded: boolean
}
function freshTabState(): TabState {
return { items: [], total: 0, page: 1, pageSize: 10, loading: false, errorMsg: null, loaded: false }
}
const route = useRoute()
const router = useRouter()
const channelStore = useChannelStore()
// activeTab 跟随全局 channelStore(StaffList viewGroups 先 setChannel 再 push /groups)。
// channel 总是具体值('wechat' | 'whatsapp'),不会是 'all'。
const activeTab = ref<TabKey>(
route.query.channel === 'whatsapp' || (!route.query.channel && channelStore.channel === 'whatsapp')
? 'whatsapp'
: 'wechat',
)
const tabState = reactive<Record<TabKey, TabState>>({
wechat: freshTabState(),
whatsapp: freshTabState(),
})
// 全字段抽屉(两 tab 共享)
const drawerOpen = ref(false)
const drawerGroup = ref<Group | null>(null)
// query 过滤(两 tab 共享)
const ownerId = computed<string | null>(() =>
route.query.owner_account_id ? String(route.query.owner_account_id) : null,
)
// ---- cell helpers (structured-first via groupFields.readField) ----
function cell(row: Group, camelKey: string): unknown {
return readField(row, camelKey)
}
function cellText(row: Group, camelKey: string): string {
const v = cell(row, camelKey)
return v === null || v === undefined || v === '' ? '--' : String(v)
}
/** 时间列:number→formatTime;字符串直显;空→--。 */
function cellTimeText(row: Group, camelKey: string): string {
const v = cell(row, camelKey)
if (typeof v === 'number' && Number.isFinite(v)) return formatTime(v)
if (typeof v === 'string' && v.trim()) return v.trim()
return '--'
}
/** 数字列,缺失回 0。 */
function cellNum(row: Group, camelKey: string, fallback = 0): number {
const v = cell(row, camelKey)
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
/** 数据来源列:fromType 代码→文案(复用 customerFields.fromTypeLabel)。 */
function fromTypeText(row: Group): string {
return fromTypeLabel(cell(row, 'fromType'))
}
function imageCell(row: Group, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
/** WhatsApp tab 行序号(含分页偏移)。 */
function waSerial(index: number): number {
const st = tabState.whatsapp
return (st.page - 1) * st.pageSize + index + 1
}
// ---- per-tab loading ----
async function loadTab(ch: TabKey): Promise<void> {
const st = tabState[ch]
st.loading = true
st.errorMsg = null
try {
const res = await getGroups({
channel: ch,
page: st.page,
page_size: st.pageSize,
owner_account_id: ownerId.value ?? undefined,
})
st.items = res.items
st.total = res.total
st.loaded = true
} catch (e) {
st.errorMsg = (e as Error).message
ElMessage.error(`群列表加载失败:${(e as Error).message}`)
} finally {
st.loading = false
}
}
function handlePageChange(ch: TabKey, p: number): void {
tabState[ch].page = p
loadTab(ch)
}
function handlePageSizeChange(ch: TabKey, s: number): void {
tabState[ch].pageSize = s
tabState[ch].page = 1
loadTab(ch)
}
function onWechatPageChange(p: number): void { handlePageChange('wechat', p) }
function onWechatPageSizeChange(s: number): void { handlePageSizeChange('wechat', s) }
function onWhatsappPageChange(p: number): void { handlePageChange('whatsapp', p) }
function onWhatsappPageSizeChange(s: number): void { handlePageSizeChange('whatsapp', s) }
// 侧栏菜单在同一页面内切换 ?channel= 时组件复用(不重挂载);go() 同时 setChannel,
// 下方 channelStore watch 负责同步 activeTab + lazy-load。
// ---- 操作列:查看聊天记录 → group-chat ----
function viewChat(row: Group): void {
if (!row.owner_account_id) {
ElMessage.warning('该群缺少归属账号,无法查看聊天记录')
return
}
router.push({
name: 'group-chat',
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id },
})
}
/** drawer 内「查看聊天记录」入口:drawerGroup 是 ref<Group|null>,模板嵌套不保证窄化,用 wrapper 守卫。 */
function viewChatFromDrawer(): void {
const row = drawerGroup.value
if (!row) return
viewChat(row)
}
/**
* 微信群「导出HTML聊天记录」→ 后端 /api/export/chat-html(room_id)。
* 云客群列表导出按钮直接导出,无时间选择 modal(仅微信客户有「请选择时间」)。
* WA 群 tab 无此操作(云客 WA 群列表无导出)。
*/
async function exportGroupHtml(row: Group): Promise<void> {
if (!row.owner_account_id || !row.room_id) {
ElMessage.warning('该群缺少归属账号/群标识,无法导出')
return
}
try {
await downloadBlob('/export/chat-html', {
channel: row.channel,
owner_account_id: row.owner_account_id,
room_id: row.room_id,
})
ElMessage.success('导出已开始')
} catch (e) {
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
}
}
// ---- header 导出:当前 tab 渠道全量群 CSV(含当前员工筛选) ----
const exporting = ref(false)
async function exportGroups(): Promise<void> {
const channel = activeTab.value // 'wechat' | 'whatsapp'
if (tabState[channel].total === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv({
fetchPage: (page, pageSize) =>
getGroups({
channel,
owner_account_id: ownerId.value ?? undefined,
page,
page_size: pageSize,
}),
columns: [
{ key: 'room_name', label: '群名' },
{ key: 'room_id', label: '群ID' },
{ key: 'owner_account_nickname', label: '群主', format: (r) => r.owner_account_nickname ?? r.owner_account_id ?? '' },
{ key: 'member_count', label: '成员数' },
{ key: 'message_count', label: '消息数' },
{ key: 'last_chat_time', label: '最新消息时间', format: (r) => (r.last_chat_time ? formatTime(r.last_chat_time) : '') },
{ key: 'owner_account_id', label: '归属员工' },
],
filename: `${channel === 'wechat' ? '微信群' : 'WA群'}列表`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
/** 清除 owner_account_id query(保留其余 query)。 */
function clearOwnerFilter(): void {
const { owner_account_id: _drop, ...rest } = route.query
void _drop
router.replace({ query: rest })
}
// ---- drawer ----
function openDrawer(row: Group): void {
drawerGroup.value = row
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerGroup.value ? buildGroupSections(drawerGroup.value) : [],
)
const drawerAvatarSrc = computed<string | undefined>(() => {
const g = drawerGroup.value
if (!g) return undefined
// Group 类型无 head_image_url 结构化列(head_image_url 只在 Customer 上);
// imageCell 经 groupFields.readField 已读 STRUCTURED_ALIAS + raw_json.headImageUrl。
return imageCell(g, 'headImageUrl') ?? undefined
})
const drawerCanChat = computed(() => !!drawerGroup.value?.owner_account_id)
// 全局 channel 变化 → activeTab 跟随 + lazy-load(同 CustomerList)。
watch(
() => channelStore.channel,
(ch) => {
const next = ch === 'whatsapp' ? 'whatsapp' : 'wechat'
if (next !== activeTab.value) {
activeTab.value = next
if (!tabState[next].loaded) loadTab(next)
}
},
)
// query(owner)变化 → 重置两 tab page + 重载当前 tab。
watch(
() => ownerId.value,
() => {
tabState.wechat.loaded = false
tabState.whatsapp.loaded = false
tabState.wechat.page = 1
tabState.whatsapp.page = 1
loadTab(activeTab.value)
},
)
onMounted(() => {
loadTab(activeTab.value)
})
</script>
<template>
<div class="yk-groups">
<div class="yk-groups__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}群列表</h2>
<span class="yk-groups__sub">
共 {{ tabState[activeTab].total }} 条
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
</span>
</div>
<div class="yk-groups__filters">
<el-button size="small" :loading="exporting" @click="exportGroups">
<el-icon><Download /></el-icon> 导出
</el-button>
<el-button v-if="ownerId" size="small" type="info" plain @click="clearOwnerFilter">
清除员工筛选
</el-button>
</div>
</div>
<!-- ============================================================
微信群 — 7 列,对齐云客 wechatdetail/2/{wxId}.
============================================================ -->
<template v-if="activeTab === 'wechat'">
<el-alert
v-if="tabState.wechat.errorMsg"
:title="tabState.wechat.errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="tabState.wechat.loading"
:data="tabState.wechat.items"
stripe
border
row-key="room_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无微信群数据"
@row-click="openDrawer"
>
<el-table-column label="群信息" min-width="180">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'chatRoomName')">{{ cellText(row, 'chatRoomName') }}</div>
</template>
</el-table-column>
<el-table-column label="数据来源" min-width="110">
<template #default="{ row }">{{ fromTypeText(row) }}</template>
</el-table-column>
<el-table-column label="群主信息" min-width="180">
<template #default="{ row }">
<div class="yk-group-owner">
<el-avatar :size="36" :src="imageCell(row, 'ownerImage') || undefined" shape="circle">
{{ (cellText(row, 'ownerName') || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-group-owner__meta">
<div class="yk-staffinfo__nick">{{ cellText(row, 'ownerName') }}</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'realName') }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="群成员数量" width="110" align="right">
<template #default="{ row }">{{ cellNum(row, 'memberCount') }}</template>
</el-table-column>
<el-table-column label="群成员好友数量" width="130" align="right">
<template #default="{ row }">{{ cellNum(row, 'friendCount') }}</template>
</el-table-column>
<el-table-column label="消息数量" width="100" align="right">
<template #default="{ row }">{{ cellNum(row, 'messageCount') }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML聊天记录</el-button>
</template>
</el-table-column>
</el-table>
<div class="yk-groups__pager">
<el-pagination
:current-page="tabState.wechat.page"
:page-size="tabState.wechat.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="tabState.wechat.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWechatPageChange"
@size-change="onWechatPageSizeChange"
/>
</div>
</template>
<!-- ============================================================
WhatsApp群 — 8 列,对齐云客 waBusiness/friendlist 群列表 tab.
⚠️ WA 群抓取缺失 → 空表;字段推断,待 WA 抓取实现后核对。
============================================================ -->
<template v-if="activeTab === 'whatsapp'">
<el-alert
v-if="tabState.whatsapp.errorMsg"
:title="tabState.whatsapp.errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="tabState.whatsapp.loading"
:data="tabState.whatsapp.items"
stripe
border
row-key="room_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无 WhatsApp 群数据"
@row-click="openDrawer"
>
<el-table-column label="序号" width="70" align="center">
<template #default="{ $index }">{{ waSerial($index) }}</template>
</el-table-column>
<el-table-column label="员工信息" min-width="150">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'salesNickName')">{{ cellText(row, 'salesNickName') }}</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'salePhone') }}</div>
</template>
</el-table-column>
<el-table-column label="员工WhatsApp" min-width="200">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'userName')">{{ cellText(row, 'userName') }}</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'uid') }}</div>
</template>
</el-table-column>
<el-table-column label="群信息" min-width="180">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'chatRoomName')">{{ cellText(row, 'chatRoomName') }}</div>
</template>
</el-table-column>
<el-table-column label="群消息数量" width="120" align="right">
<template #default="{ row }">{{ cellNum(row, 'messageCount') }}</template>
</el-table-column>
<el-table-column label="添加时间" width="160">
<template #default="{ row }">{{ cellTimeText(row, 'createTime') }}</template>
</el-table-column>
<el-table-column label="最新消息时间" width="160">
<template #default="{ row }">{{ cellTimeText(row, 'lastUpdate') }}</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="viewChat(row)">
查看聊天记录
</el-button>
</template>
</el-table-column>
</el-table>
<div class="yk-groups__pager">
<el-pagination
:current-page="tabState.whatsapp.page"
:page-size="tabState.whatsapp.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="tabState.whatsapp.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWhatsappPageChange"
@size-change="onWhatsappPageSizeChange"
/>
</div>
</template>
<p class="yk-groups__hint">点击任意一行查看群全部字段。</p>
<!-- 全字段抽屉(保留现状:avatar + buildGroupSections + 查看聊天记录) -->
<el-drawer
v-model="drawerOpen"
:title="drawerGroup ? `群详情 · ${drawerGroup.room_name ?? drawerGroup.room_id}` : '群详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerGroup">
<div class="yk-drawer__topbar">
<el-button type="primary" size="small" :disabled="!drawerCanChat" @click="viewChatFromDrawer">
查看聊天记录
</el-button>
<span v-if="!drawerCanChat" class="yk-drawer__topbar-hint">该群缺少归属账号,无法查看聊天</span>
</div>
<div class="yk-drawer__avatar">
<el-avatar :size="56" :src="drawerAvatarSrc" shape="square">
{{ (drawerGroup.room_name || drawerGroup.room_id || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ drawerGroup.room_name || drawerGroup.room_id }}
</div>
<div class="yk-drawer__avatar-sub">
{{ channelLabel(drawerGroup.channel) }}
<template v-if="cell(drawerGroup, 'wechatNickName')"> · {{ cellText(drawerGroup, 'wechatNickName') }}</template>
</div>
</div>
</div>
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
<div v-for="f in section.fields" :key="f.key" class="yk-drawer__kv-item">
<dt>{{ f.label }}</dt>
<dd v-if="f.render === 'image'" class="yk-drawer__img">
<el-image :src="String(f.value)" :preview-src-list="[String(f.value)]" fit="cover" hide-on-click-modal />
</dd>
<dd v-else-if="f.render === 'empty'" class="yk-drawer__empty">—</dd>
<dd v-else>{{ f.text }}</dd>
</div>
</dl>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.yk-groups__header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 12px; }
.yk-groups__sub { color: #909399; font-size: 13px; }
.yk-groups__filters { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.yk-groups__pager { margin-top: 12px; display: flex; justify-content: flex-end; }
.yk-groups__hint { margin: 8px 2px 0; font-size: 12px; color: #c0c4cc; }
.yk-staffinfo__nick { font-weight: 600; font-size: 13px; color: #303133; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.yk-staffinfo__muted { font-size: 12px; color: #909399; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.yk-drawer__topbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.yk-drawer__topbar-hint { color: #c0c4cc; font-size: 12px; }
.yk-drawer__avatar { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
.yk-drawer__avatar-meta { min-width: 0; }
.yk-drawer__avatar-name { font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.yk-drawer__avatar-sub { font-size: 12px; color: #909399; margin-top: 2px; }
.yk-drawer__kv { margin: 0 0 8px; }
.yk-drawer__kv-item { display: grid; grid-template-columns: 110px 1fr; gap: 8px 12px; padding: 3px 0; }
.yk-drawer__kv dt { color: #909399; font-size: 13px; word-break: break-all; }
.yk-drawer__kv dd { margin: 0; font-size: 13px; color: #303133; word-break: break-all; min-width: 0; }
.yk-drawer__img :deep(.el-image) { width: 64px; height: 64px; border-radius: 4px; border: 1px solid var(--el-border-color-light, #e4e7ed); }
.yk-drawer__empty { color: #c0c4cc; }
.yk-group-owner {
display: flex;
align-items: center;
gap: 10px;
}
.yk-group-owner__meta {
min-width: 0;
}
</style>