feat(staffchat): 通讯录(客户表+群表列对齐云客+批量勾选导出+分页)+朋友圈翻页失败内联重试

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-25 18:56:02 +08:00
parent 1b66be6a53
commit 0371484dad
4 changed files with 575 additions and 4 deletions
+6
View File
@@ -49,6 +49,8 @@ export interface CustomerQuery {
department_id?: string
add_time_since?: number
last_chat_since?: number
/** 文本搜索: 昵称/微信号/备注名/手机号子串匹配(通讯录搜索框)。 */
q?: string
}
export interface DeclaredTotals {
@@ -73,6 +75,7 @@ export function getCustomers(query: CustomerQuery = {}): Promise<Page<Customer>>
department_id: query.department_id,
add_time_since: query.add_time_since,
last_chat_since: query.last_chat_since,
q: query.q,
}),
)
}
@@ -162,6 +165,8 @@ export interface GroupQuery {
user_id?: string
/** 精确单行直查(群聊页群名解析)。 */
room_id?: string
/** 群名称模糊搜索(通讯录群 tab 搜索框)。 */
q?: string
page?: number
page_size?: number
}
@@ -174,6 +179,7 @@ export function getGroups(query: GroupQuery = {}): Promise<Page<Group>> {
owner_account_id: query.owner_account_id,
user_id: query.user_id,
room_id: query.room_id,
q: query.q,
page: query.page ?? 1,
page_size: query.page_size ?? 20,
}),
+9
View File
@@ -98,6 +98,15 @@ export interface Group {
message_count?: number | null
/** ms-epoch of most recent message */
last_chat_time?: number | null
/** 群主/建群补充列(2026-08-25 对齐云客微信群 tab;scraper/回填脚本提取,可空)。 */
owner_name?: string | null
owner_wxid?: string | null
owner_alias?: string | null
owner_avatar?: string | null
/** 群成员中员工好友数 */
friend_count?: number | null
/** 建群时间 ms-epoch */
create_time?: number | null
/**
* Verbatim upstream chatroom record (camelCase keys: chatRoomId/chatRoomName,
* memberCount/memberIds, headImageUrl, wechatId/wechatNickName, userName/
@@ -1,8 +1,545 @@
<!-- 占位组件: Task 13 实现通讯录视图,签名锁定如下。 -->
<!-- 员工聊天窗口右列·通讯录(对齐云客 wechat-window 通讯录 tab): 微信客户/微信群双表 +
搜索(客户 q=昵称/微信号/备注名/手机号,群 q=群名称,均服务端过滤) + 查询/导出CSV/
批量勾选导出HTML聊天记录(串行+300ms间隔) + 10条/页分页。
跳转单聊/群聊走本组件内部 router.push(staff-chat),不向父组件 emit。
根元素必须保持单 div——父组件 StaffChat 靠 attrs fallthrough 挂 sc-root__pane 列宽。 -->
<script setup lang="ts">
defineProps<{ ownerId: string }>()
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getCustomers, getGroups } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Customer, Group } from '@/api/types'
import { formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport'
const props = defineProps<{ ownerId: string }>()
const router = useRouter()
/** 云客通讯录每页 10 条(两 tab 同)。 */
const PAGE_SIZE = 10
type TabKey = 'customers' | 'groups'
const activeTab = ref<TabKey>('customers')
/** 输入框值 / 已应用值:点「查询」(或回车)才生效重查,对齐云客。 */
const keyword = ref('')
const appliedKeyword = ref('')
// ---- 客户表状态 ----
const customers = ref<Customer[]>([])
const customerTotal = ref(0)
const customerPage = ref(1)
const customerLoading = ref(false)
const customerError = ref<string | null>(null)
const selectedCustomers = ref<Customer[]>([])
// ---- 群表状态 ----
const groups = ref<Group[]>([])
const groupTotal = ref(0)
const groupPage = ref(1)
const groupLoading = ref(false)
const groupError = ref<string | null>(null)
const selectedGroups = ref<Group[]>([])
async function loadCustomers(): Promise<void> {
customerLoading.value = true
customerError.value = null
try {
const res = await getCustomers({
channel: 'wechat',
owner_account_id: props.ownerId,
q: appliedKeyword.value || undefined,
page: customerPage.value,
page_size: PAGE_SIZE,
})
customers.value = res.items
customerTotal.value = res.total
} catch (e) {
customerError.value = (e as Error).message
customers.value = []
customerTotal.value = 0
} finally {
customerLoading.value = false
}
}
async function loadGroups(): Promise<void> {
groupLoading.value = true
groupError.value = null
try {
const res = await getGroups({
channel: 'wechat',
owner_account_id: props.ownerId,
q: appliedKeyword.value || undefined,
page: groupPage.value,
page_size: PAGE_SIZE,
})
groups.value = res.items
groupTotal.value = res.total
} catch (e) {
groupError.value = (e as Error).message
groups.value = []
groupTotal.value = 0
} finally {
groupLoading.value = false
}
}
/** 当前 tab 重查(tab 切换时总是重查,另一 tab 不会残留旧关键词的旧数据)。 */
function loadActiveTab(): void {
if (activeTab.value === 'customers') void loadCustomers()
else void loadGroups()
}
/** 查询: 应用关键词 + 两 tab 回第 1 页 + 重查当前 tab。 */
function search(): void {
appliedKeyword.value = keyword.value.trim()
customerPage.value = 1
groupPage.value = 1
loadActiveTab()
}
function onCustomerPageChange(p: number): void {
customerPage.value = p
void loadCustomers()
}
function onGroupPageChange(p: number): void {
groupPage.value = p
void loadGroups()
}
/** el-table selection-change 的模板内联箭头带 TS 注解会触发 vue 模板解析错误,收敛到 script。 */
function onCustomerSelection(rows: Customer[]): void {
selectedCustomers.value = rows
}
function onGroupSelection(rows: Group[]): void {
selectedGroups.value = rows
}
watch(activeTab, loadActiveTab)
watch(
() => props.ownerId,
() => {
keyword.value = ''
appliedKeyword.value = ''
customerPage.value = 1
groupPage.value = 1
loadActiveTab()
},
{ immediate: true },
)
// ---- 操作列: 查看聊天记录(内部跳转 single/group,无 emit) ----
function viewCustomerChat(row: Customer): void {
router.push({
name: 'staff-chat',
params: { ownerId: props.ownerId, tab: 'single', talkerId: row.friend_id },
})
}
function viewGroupChat(row: Group): void {
router.push({
name: 'staff-chat',
params: { ownerId: props.ownerId, tab: 'group', talkerId: row.room_id },
})
}
// ---- 操作列: 单行导出HTML聊天记录 ----
async function exportCustomerHtml(row: Customer): Promise<void> {
try {
await downloadBlob('/export/chat-html', {
channel: 'wechat',
owner_account_id: props.ownerId,
talker_id: row.friend_id,
})
ElMessage.success('导出已开始')
} catch (e) {
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
}
}
async function exportGroupHtml(row: Group): Promise<void> {
try {
await downloadBlob('/export/chat-html', {
channel: 'wechat',
owner_account_id: props.ownerId,
room_id: row.room_id,
})
ElMessage.success('导出已开始')
} catch (e) {
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
}
}
// ---- 批量导出HTML聊天记录(勾选行,串行 + 300ms 间隔防浏览器拦截多下载) ----
const batchExporting = ref(false)
const selectedCount = computed(() =>
activeTab.value === 'customers'
? selectedCustomers.value.length
: selectedGroups.value.length,
)
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function exportSelectedHtml(): Promise<void> {
const targets: { talker_id?: string; room_id?: string }[] =
activeTab.value === 'customers'
? selectedCustomers.value.map(c => ({ talker_id: c.friend_id }))
: selectedGroups.value.map(g => ({ room_id: g.room_id }))
if (targets.length === 0 || batchExporting.value) return
batchExporting.value = true
let done = 0
try {
for (const t of targets) {
await downloadBlob('/export/chat-html', {
channel: 'wechat',
owner_account_id: props.ownerId,
talker_id: t.talker_id,
room_id: t.room_id,
})
done++
if (done < targets.length) await sleep(300)
}
ElMessage.success(`已导出 ${done} 份`)
} catch (e) {
ElMessage.error(`导出中断(已完成 ${done}/${targets.length} 份):${(e as Error).message}`)
} finally {
batchExporting.value = false
}
}
// ---- 导出 CSV(当前 tab + 当前关键词的全量过滤结果) ----
const csvExporting = ref(false)
async function exportCsv(): Promise<void> {
if (activeTab.value === 'customers' && customerTotal.value === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
if (activeTab.value === 'groups' && groupTotal.value === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
csvExporting.value = true
try {
const result =
activeTab.value === 'customers'
? await exportListToCsv<Customer>({
fetchPage: (page, pageSize) =>
getCustomers({
channel: 'wechat',
owner_account_id: props.ownerId,
q: appliedKeyword.value || undefined,
page,
page_size: pageSize,
}),
columns: [
{ key: 'friend_nickname', label: '客户昵称' },
{ key: 'friend_id', label: '微信号' },
{ key: 'friend_remark', label: '备注名' },
{ key: 'phone', label: '手机号' },
{
key: 'last_chat_time',
label: '最近沟通时间',
format: r => (r.last_chat_time ? formatTime(r.last_chat_time) : ''),
},
{ key: 'send_today', label: '发送条数' },
{ key: 'receive_today', label: '接收条数' },
],
filename: `微信客户_${props.ownerId}`,
})
: await exportListToCsv<Group>({
fetchPage: (page, pageSize) =>
getGroups({
channel: 'wechat',
owner_account_id: props.ownerId,
q: appliedKeyword.value || undefined,
page,
page_size: pageSize,
}),
columns: [
{ key: 'room_name', label: '群名称', format: r => r.room_name ?? r.room_id },
{ key: 'room_id', label: '群ID' },
{ key: 'owner_name', label: '群主昵称' },
{ key: 'owner_wxid', label: '群主微信号' },
{ key: 'member_count', label: '群成员数量' },
{ key: 'friend_count', label: '群成员好友数量' },
{ key: 'message_count', label: '消息数量' },
],
filename: `微信群_${props.ownerId}`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
csvExporting.value = false
}
}
// ---- cell helpers ----
/** 客户显示名: 昵称空时回退 friend_id。 */
function nickOrId(row: Customer): string {
return row.friend_nickname || row.friend_id
}
/** 发送/接收条数: send_today(后端今日聚合,总有值)缺省时回退 raw_json.sendNum。 */
function rawNum(row: Customer, camelKey: string): number {
const v = row.raw_json?.[camelKey]
return typeof v === 'number' && Number.isFinite(v) ? v : 0
}
/** 文本列: 空显 -。 */
function textOrDash(v: string | null | undefined): string {
return v && v.trim() ? v : '-'
}
</script>
<template>
<div class="yk-empty">通讯录 — Task 13 实现</div>
<div class="sc-contacts yk-panel-bg yk-panel-sep">
<!-- ── 顶部: 客户/群 tab + 搜索框 + 按钮行(查询/导出CSV/批量导出聊天记录) ── -->
<div class="sc-contacts__top">
<el-tabs v-model="activeTab" class="sc-contacts__tabs">
<el-tab-pane label="微信客户" name="customers" />
<el-tab-pane label="微信群" name="groups" />
</el-tabs>
<div class="sc-contacts__row">
<el-input
v-model="keyword"
size="small"
clearable
class="sc-contacts__search"
:placeholder="
activeTab === 'customers'
? '支持微信昵称、微信号、备注名、手机号搜索'
: '请输入群名称'
"
@keyup.enter="search"
/>
<el-button size="small" type="primary" @click="search">查询</el-button>
<el-button size="small" :loading="csvExporting" @click="exportCsv">
<el-icon><Download /></el-icon>&nbsp;导出
</el-button>
<el-button
size="small"
type="primary"
plain
:disabled="selectedCount === 0"
:loading="batchExporting"
@click="exportSelectedHtml"
>导出聊天记录</el-button>
</div>
</div>
<!-- ── 表格区(列内滚动) ── -->
<div class="sc-contacts__body">
<!-- 微信客户表(云客 7+1 列) -->
<template v-if="activeTab === 'customers'">
<el-alert v-if="customerError" :title="customerError" type="error" :closable="false" />
<el-table
v-loading="customerLoading"
:data="customers"
row-key="friend_id"
stripe
empty-text="暂无客户数据"
@selection-change="onCustomerSelection"
>
<el-table-column type="selection" width="42" />
<el-table-column label="客户微信" min-width="190">
<template #default="{ row }">
<div class="sc-contacts__user">
<el-avatar :size="32" :src="row.head_image_url ?? undefined">
{{ nickOrId(row).slice(0, 1) }}
</el-avatar>
<div class="sc-contacts__meta">
<div class="sc-contacts__name" :title="nickOrId(row)">{{ nickOrId(row) }}</div>
<div class="sc-contacts__sub" :title="row.friend_id">{{ row.friend_id }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="数据来源" width="90">
<template #default>工作手机</template>
</el-table-column>
<el-table-column label="客户备注名" min-width="100">
<template #default="{ row }">{{ textOrDash(row.friend_remark) }}</template>
</el-table-column>
<el-table-column label="最近沟通时间" width="150">
<template #default="{ row }">{{ formatTime(row.last_chat_time) }}</template>
</el-table-column>
<el-table-column label="发送条数" width="85" align="right">
<template #default="{ row }">{{ row.send_today ?? rawNum(row, 'sendNum') }}</template>
</el-table-column>
<el-table-column label="接收条数" width="85" align="right">
<template #default="{ row }">{{ row.receive_today ?? rawNum(row, 'receiveNum') }}</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="viewCustomerChat(row)">
查看聊天记录
</el-button>
<el-button link type="primary" size="small" @click="exportCustomerHtml(row)">
导出HTML聊天记录
</el-button>
</template>
</el-table-column>
</el-table>
<div class="sc-contacts__pager">
<el-pagination
:current-page="customerPage"
:page-size="PAGE_SIZE"
:total="customerTotal"
layout="total, prev, pager, next"
background
@current-change="onCustomerPageChange"
/>
</div>
</template>
<!-- 微信群表(云客 8+1 列) -->
<template v-else>
<el-alert v-if="groupError" :title="groupError" type="error" :closable="false" />
<el-table
v-loading="groupLoading"
:data="groups"
row-key="room_id"
stripe
empty-text="暂无群数据"
@selection-change="onGroupSelection"
>
<el-table-column type="selection" width="42" />
<el-table-column label="群信息" min-width="170">
<template #default="{ row }">
<div class="sc-contacts__name" :title="row.room_name || row.room_id">
{{ row.room_name || row.room_id }}
</div>
</template>
</el-table-column>
<el-table-column label="数据来源" width="90">
<template #default>工作手机</template>
</el-table-column>
<el-table-column label="群主信息" min-width="130">
<template #default="{ row }">
<div class="sc-contacts__owner">
<div class="sc-contacts__name" :title="row.owner_name ?? undefined">
{{ textOrDash(row.owner_name) }}
</div>
<div class="sc-contacts__sub" :title="row.owner_wxid ?? undefined">
{{ textOrDash(row.owner_wxid) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="群成员数量" width="100" align="right">
<template #default="{ row }">{{ row.member_count ?? 0 }}</template>
</el-table-column>
<el-table-column label="群成员好友数量" width="120" align="right">
<template #default="{ row }">{{ row.friend_count ?? 0 }}</template>
</el-table-column>
<el-table-column label="消息数量" width="90" align="right">
<template #default="{ row }">{{ row.message_count ?? 0 }}</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="viewGroupChat(row)">
查看聊天记录
</el-button>
<el-button link type="primary" size="small" @click="exportGroupHtml(row)">
导出HTML聊天记录
</el-button>
</template>
</el-table-column>
</el-table>
<div class="sc-contacts__pager">
<el-pagination
:current-page="groupPage"
:page-size="PAGE_SIZE"
:total="groupTotal"
layout="total, prev, pager, next"
background
@current-change="onGroupPageChange"
/>
</div>
</template>
</div>
</div>
</template>
<style scoped>
/* ══ 通讯录右列(墨玉主题,手法沿用 SessionPane/MomentsPane) ══
点阵底/右缘玉绿线走全局 yk-panel-bg/yk-panel-sep(style.css);这里只写布局。 */
.sc-contacts {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
/* ── 顶部区(tab + 搜索 + 按钮行,随列固定不滚动) ── */
.sc-contacts__top {
flex: none;
padding: 4px 12px 0;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(23, 29, 43, 0.05);
}
.sc-contacts__tabs :deep(.el-tabs__header) {
margin: 0 0 6px;
}
.sc-contacts__row {
display: flex;
gap: 8px;
padding-bottom: 10px;
}
.sc-contacts__search {
flex: 1;
min-width: 0;
}
/* ── 表格区(列内滚动) ── */
.sc-contacts__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
}
.sc-contacts__pager {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
/* 客户微信富单元格(头像 + 昵称 + 微信号,手法对齐 SessionPane 会话卡) */
.sc-contacts__user {
display: flex;
align-items: center;
gap: 8px;
}
.sc-contacts__user .el-avatar {
flex: none;
border-radius: 8px;
border: 1px solid rgba(23, 29, 43, 0.05);
box-shadow: 0 1px 3px rgba(23, 29, 43, 0.06);
}
.sc-contacts__meta {
min-width: 0;
}
.sc-contacts__name {
font-size: 13px;
font-weight: 600;
color: #2b3240;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sc-contacts__sub {
margin-top: 2px;
font-size: 11.5px;
color: #8a94a4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 群主信息两行(昵称 + wxid,可空显 -) */
.sc-contacts__owner {
min-width: 0;
}
</style>
@@ -112,7 +112,9 @@ const headerInitial = computed(
</div>
</div>
<el-alert v-if="errorMsg" :title="errorMsg" type="error" :closable="false" />
<!-- 首屏失败才在顶部 alert;翻页失败(items>0)改在 feed 底部内联提示+重试,
避免反馈出现在远离滚动位置的地方(Task 12 评审 Important #1)。 -->
<el-alert v-if="errorMsg && items.length === 0" :title="errorMsg" type="error" :closable="false" />
<!-- ── feed 流(列内滚动,距底 100px 翻页) ── -->
<div
@@ -166,6 +168,12 @@ const headerInitial = computed(
<div v-if="loading && items.length > 0" class="sc-moments__more">加载中…</div>
<div v-else-if="!hasMore && items.length > 0" class="sc-moments__more">已到底</div>
<!-- 翻页失败的内联错误+重试(page 未推进,loadMore 重试同一页) -->
<div
v-if="errorMsg && items.length > 0"
class="sc-moments__retry"
@click="loadMore"
>加载失败,点击重试</div>
</div>
</div>
</div>
@@ -307,4 +315,15 @@ const headerInitial = computed(
color: #8a94a4;
text-align: center;
}
/* 翻页失败内联重试行 */
.sc-moments__retry {
padding: 6px 0 2px;
font-size: 11.5px;
color: #f56c6c;
text-align: center;
cursor: pointer;
}
.sc-moments__retry:hover {
text-decoration: underline;
}
</style>