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

693 lines
29 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 { getCustomers } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Channel, Customer } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { useOrgStore } from '@/stores/org'
import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport'
import { buildCustomerSections, fromTypeLabel, readField } from '@/utils/customerFields'
/**
* 客户列表 — channel-tabbed view mirroring 云客「沟通」板块的客户列表.
*
* 微信客户 tab 对齐 云客 /cms/wechat/wechatlist/wechatdetail/1/{wxId}
* (16 列: 客户信息/手机号码/描述/地区/个性签名/好友标签/发送消息数/接收消息数/
* 最近联系时间/员工微信/员工信息/创建时间/添加时间/添加类型/好友来源/好友类型 + 操作).
* WhatsApp客户 tab 对齐 云客 /cms/wechat/waBusiness/friendlist
* (7 列: 序号/员工信息/员工WhatsApp/客户WhatsApp/添加时间/最新消息时间/操作).
* 两 channel 列不同 → 各自独立列定义(同 StaffList 模式)。
* 操作列「查看聊天记录」→ ChatTimeline;row-click 开全字段 drawer。
* 读字段用 readField(structured-first + raw_json fallback);时间用 cellTimeText
* (number→formatTime,字符串直显)。
*/
type TabKey = Channel
interface TabState {
items: Customer[]
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()
const org = useOrgStore()
// activeTab 跟随全局 channelStore(StaffList viewCustomers 先 setChannel 再 push /customers)。
// channel 总是具体值('wechat' | 'whatsapp'),不会是 'all' —— CustomerList 强制按 tab 选 channel,
// loadTab 永不传 'all'(getCustomers 的 'all'→undefined 分支不会被触发)。
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 drawerCustomer = ref<Customer | null>(null)
// query 过滤(两 tab 共享)
const departmentId = computed<string | null>(() =>
route.query.department_id ? String(route.query.department_id) : null,
)
const ownerId = computed<string | null>(() =>
route.query.owner_account_id ? String(route.query.owner_account_id) : null,
)
const departmentName = computed(() => {
if (!departmentId.value) return null
return org.findNode(departmentId.value)?.label ?? departmentId.value
})
// ---- cell helpers (structured-first via customerFields.readField) ----
function cell(row: Customer, camelKey: string): unknown {
return readField(row, camelKey)
}
function cellText(row: Customer, camelKey: string): string {
const v = cell(row, camelKey)
return v === null || v === undefined || v === '' ? '--' : String(v)
}
/** 时间列:number→formatTime;字符串("YYYY-MM-DD HH:mm[:ss]")直显;空→--。 */
function cellTimeText(row: Customer, 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: Customer, camelKey: string, fallback = 0): number {
const v = cell(row, camelKey)
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
/** 数据来源列:fromType 代码→文案。 */
function fromTypeText(row: Customer): string {
return fromTypeLabel(cell(row, 'fromType'))
}
/** 添加类型列:addType 数值代码→文案(未知值原样透出)。 */
function addTypeText(row: Customer): string {
const v = cell(row, 'addType')
const n = Number(v)
if (n === 1) return '搜索添加'
if (n === 2) return '群聊添加'
if (n === 3) return '手机号添加'
if (n === 4) return '二维码添加'
if (n === 5) return '名片分享添加'
const s = String(v ?? '').trim()
return s && s !== 'null' && s !== 'undefined' && s !== '0' ? s : '--'
}
/** 好友类型列:friendType 数值代码→文案。 */
function friendTypeText(row: Customer): string {
const v = cell(row, 'friendType')
const n = Number(v)
if (n === 1) return '微信好友'
if (n === 2) return '企业微信好友'
if (n === 3) return '群好友'
const s = String(v ?? '').trim()
return s && s !== 'null' && s !== 'undefined' && s !== '0' ? s : '--'
}
/** 好友标签列:contactLabelValues 可能是字符串数组/对象数组/单值,统一成顿号分隔文本。 */
function contactLabelsText(row: Customer): string {
const v = cell(row, 'contactLabelValues')
if (Array.isArray(v)) {
const parts = v
.map((item) => {
if (typeof item === 'string') return item.trim()
if (item !== null && typeof item === 'object' && 'labelName' in item) {
return String((item as { labelName?: unknown }).labelName ?? '').trim()
}
return String(item ?? '').trim()
})
.filter((s) => s !== '')
return parts.length > 0 ? parts.join('、') : '--'
}
return cellText(row, 'contactLabelValues')
}
function imageCell(row: Customer, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
/** 客户微信 复合单元格:头像 fallback 首字(昵称→friend_id)。 */
function initials(row: Customer): string {
const nick = cell(row, 'friendWxNickname')
const source =
typeof nick === 'string' && nick ? nick : row.friend_nickname ?? row.friend_id ?? '?'
return String(source).slice(0, 1) || '?'
}
/** 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 getCustomers({
channel: ch,
page: st.page,
page_size: st.pageSize,
department_id: departmentId.value ?? undefined,
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)
}
// per-tab 包装,让 template 事件带正确类型(vue-tsc strict 不推 inline-arrow 参数)
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。
// ---- 操作列:查看聊天记录 → ChatTimeline ----
function viewChat(row: Customer): void {
if (!row.owner_account_id) {
ElMessage.warning('该客户缺少归属账号,无法查看聊天记录')
return
}
router.push({
name: 'chat',
params: {
channel: row.channel,
ownerId: row.owner_account_id,
talkerId: row.friend_id,
},
})
}
/** drawer 内「查看聊天记录」入口:drawerCustomer 是 ref<Customer|null>,模板里跨多层嵌套不保证 TS 窄化,故用 wrapper 守卫(同现状 goChatFromDrawer 模式)。 */
function viewChatFromDrawer(): void {
const row = drawerCustomer.value
if (!row) return
viewChat(row)
}
// ---- 操作列:导出HTML聊天记录 → 弹「请选择时间」→ /api/export/chat-html ----
// 对齐云客:先弹时间范围 modal(可选,不选=全部),确定后下载。
const exportOpen = ref(false)
const exportRow = ref<Customer | null>(null)
// el-date-picker value-format="X" 绑定秒级 unix 字符串数组;confirmExport 里 *1000 转毫秒。
const exportRange = ref<[string, string] | null>(null)
function openExport(row: Customer): void {
if (!row.owner_account_id || !row.friend_id) {
ElMessage.warning('该客户缺少归属账号/客户标识,无法导出')
return
}
exportRow.value = row
exportRange.value = null
exportOpen.value = true
}
async function confirmExport(): Promise<void> {
const row = exportRow.value
if (!row) return
const params: Record<string, unknown> = {
channel: row.channel,
owner_account_id: row.owner_account_id,
talker_id: row.friend_id,
}
if (exportRange.value) {
// value-format="X" 返回秒级字符串,后端要毫秒
params.start = Number(exportRange.value[0]) * 1000
params.end = Number(exportRange.value[1]) * 1000
}
exportOpen.value = false
try {
await downloadBlob('/export/chat-html', params)
ElMessage.success('导出已开始')
} catch (e) {
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
}
}
// ---- header 导出:当前 tab 渠道全量客户 CSV(含当前部门/员工筛选) ----
const exporting = ref(false)
async function exportCustomers(): 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) =>
getCustomers({
channel,
department_id: departmentId.value ?? undefined,
owner_account_id: ownerId.value ?? undefined,
page,
page_size: pageSize,
}),
columns: [
{ key: 'friend_nickname', label: '客户昵称' },
{ key: 'friend_id', label: '客户微信号' },
{ key: 'friend_remark', label: '备注名' },
{ key: 'real_name', label: '姓名' },
{ key: 'phone', label: '手机号' },
{ key: 'owner_account_id', label: '归属员工' },
{ key: 'last_chat_time', label: '最近沟通时间', format: (r) => (r.last_chat_time ? formatTime(r.last_chat_time) : '') },
],
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: Customer): void {
drawerCustomer.value = row
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerCustomer.value ? buildCustomerSections(drawerCustomer.value) : [],
)
const drawerAvatarSrc = computed<string | undefined>(() => {
const c = drawerCustomer.value
if (!c) return undefined
// imageCell 内部 readField 因 STRUCTURED_ALIAS.friendHeadImageUrl→head_image_url 已优先取结构化列,
// 再 fallback raw_json.friendHeadImageUrl,故无需再 ?? c.head_image_url(避免冗余)。
return imageCell(c, 'friendHeadImageUrl')
})
const drawerCanChat = computed(() => !!drawerCustomer.value?.owner_account_id)
// 全局 channel 变化(别处 setChannel)→ activeTab 跟随 + lazy-load。
// 当前所有外部 setChannel 入口(StaffList.viewCustomers/viewGroups)都同时带 owner_account_id query,
// watch(dept/owner) 会 fire 兜底加载;但若未来出现「只 setChannel 不带 query」的入口,这里补的
// lazy-load 能保证 tab 切了数据也加载(与 StaffList.onTabChange 契约一致)。
watch(
() => channelStore.channel,
(ch) => {
const next = ch === 'whatsapp' ? 'whatsapp' : 'wechat'
if (next !== activeTab.value) {
activeTab.value = next
if (!tabState[next].loaded) loadTab(next)
}
},
)
// query(部门/员工)变化 → 重载当前 tab(filter 共享)
watch(
() => [departmentId.value, ownerId.value] as const,
() => {
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-customers">
<div class="yk-customers__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}客户列表</h2>
<span class="yk-customers__sub">
共 {{ tabState[activeTab].total }} 条
<template v-if="departmentName"> · 部门:{{ departmentName }}</template>
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
</span>
</div>
<div class="yk-customers__filters">
<el-button size="small" :loading="exporting" @click="exportCustomers">
<el-icon><Download /></el-icon> 导出
</el-button>
<el-button
v-if="departmentId"
size="small"
type="info"
plain
@click="router.replace({ name: 'customers' })"
>
清除部门筛选
</el-button>
<el-button v-if="ownerId" size="small" type="info" plain @click="clearOwnerFilter">
清除员工筛选
</el-button>
</div>
</div>
<!-- ============================================================
微信客户 — 16 列 + 操作,对齐云客 wechatdetail/1/{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="friend_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无微信客户数据"
@row-click="openDrawer"
>
<el-table-column label="客户信息" min-width="220">
<template #default="{ row }">
<div class="yk-wxuser">
<el-avatar :size="36" :src="imageCell(row, 'friendHeadImageUrl')" shape="square">
{{ initials(row) }}
</el-avatar>
<div class="yk-wxuser__meta">
<div class="yk-staffinfo__nick" :title="cellText(row, 'friendWxNickname')">
{{ cellText(row, 'friendWxNickname') }}
</div>
<div class="yk-staffinfo__muted" :title="cellText(row, 'friendWxId')">
{{ cellText(row, 'friendWxId') }}
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="手机号码" width="130">
<template #default="{ row }">{{ cellText(row, 'phone') }}</template>
</el-table-column>
<el-table-column label="描述" min-width="120">
<template #default="{ row }">{{ cellText(row, 'description') }}</template>
</el-table-column>
<el-table-column label="地区" width="100">
<template #default="{ row }">{{ cellText(row, 'region') }}</template>
</el-table-column>
<el-table-column label="个性签名" min-width="150">
<template #default="{ row }">{{ cellText(row, 'signature') }}</template>
</el-table-column>
<el-table-column label="好友标签" min-width="120">
<template #default="{ row }">{{ contactLabelsText(row) }}</template>
</el-table-column>
<el-table-column label="发送消息数" width="100" align="right">
<template #default="{ row }">{{ cellNum(row, 'sendNum') }}</template>
</el-table-column>
<el-table-column label="接收消息数" width="100" align="right">
<template #default="{ row }">{{ cellNum(row, 'receiveNum') }}</template>
</el-table-column>
<el-table-column label="最近联系时间" width="170">
<template #default="{ row }">{{ cellTimeText(row, 'lastChatTime') }}</template>
</el-table-column>
<el-table-column label="员工微信" min-width="220">
<template #default="{ row }">
<div class="yk-wxuser">
<el-avatar :size="36" :src="imageCell(row, 'ownerImage') ?? undefined" shape="square">
{{ (cellText(row, 'saleName') !== '--' ? cellText(row, 'saleName') : cellText(row, 'ownerName')).slice(0, 1) }}
</el-avatar>
<div class="yk-wxuser__meta">
<div class="yk-staffinfo__nick" :title="cellText(row, 'saleName')">
{{ cellText(row, 'saleName') !== '--' ? cellText(row, 'saleName') : cellText(row, 'ownerName') }}
</div>
<div class="yk-staffinfo__muted" :title="row.owner_account_id ?? ''">
微信号:{{ row.owner_account_id ?? '--' }}
</div>
<div class="yk-staffinfo__muted">
手机:{{ cellText(row, 'phone') }}
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="员工信息" min-width="140">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'saleName')">
{{ cellText(row, 'saleName') !== '--' ? cellText(row, 'saleName') : cellText(row, 'ownerName') }}
</div>
<div class="yk-staffinfo__muted" :title="cellText(row, 'phone')">
{{ cellText(row, 'phone') }}
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ cellTimeText(row, 'createTime') }}</template>
</el-table-column>
<el-table-column label="添加时间" width="170">
<template #default="{ row }">{{ cellTimeText(row, 'addTime') }}</template>
</el-table-column>
<el-table-column label="添加类型" width="100">
<template #default="{ row }">{{ addTypeText(row) }}</template>
</el-table-column>
<el-table-column label="好友来源" width="100">
<template #default="{ row }">{{ fromTypeText(row) }}</template>
</el-table-column>
<el-table-column label="好友类型" width="100">
<template #default="{ row }">{{ friendTypeText(row) }}</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="openExport(row)">
导出HTML聊天记录
</el-button>
</template>
</el-table-column>
</el-table>
<div class="yk-customers__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客户 — 7 列,对齐云客 waBusiness/friendlist.
============================================================ -->
<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="friend_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="客户WhatsApp" min-width="200">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'nickName')">{{ cellText(row, 'nickName') }}</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'phone') }}</div>
</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-customers__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-customers__hint">点击任意一行查看客户全部字段。</p>
<!-- 全字段抽屉(保留现状:avatar + buildCustomerSections + 查看聊天记录) -->
<el-drawer
v-model="drawerOpen"
:title="drawerCustomer ? `客户详情 · ${drawerCustomer.friend_nickname ?? drawerCustomer.friend_id}` : '客户详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerCustomer">
<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">
{{ (drawerCustomer.friend_nickname || drawerCustomer.friend_id || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ drawerCustomer.friend_nickname || drawerCustomer.friend_id }}
</div>
<div class="yk-drawer__avatar-sub">
{{ channelLabel(drawerCustomer.channel) }}
<template v-if="cell(drawerCustomer, 'friendWxAlias')"> · {{ cellText(drawerCustomer, 'friendWxAlias') }}</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>
<!-- 导出HTML聊天记录:时间范围选择(不选=导出全部) -->
<el-dialog v-model="exportOpen" title="请选择时间" width="420px" @close="exportRow = null">
<el-form label-width="90px">
<el-form-item label="时间范围">
<el-date-picker
v-model="exportRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="X"
format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="exportOpen = false">取 消</el-button>
<el-button type="primary" @click="confirmExport">确 定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.yk-customers__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-customers__sub { color: #909399; font-size: 13px; }
.yk-customers__filters { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.yk-customers__pager { margin-top: 12px; display: flex; justify-content: flex-end; }
.yk-customers__hint { margin: 8px 2px 0; font-size: 12px; color: #c0c4cc; }
/* 客户微信 复合单元格(头像 + 昵称 + 微信号,对齐云客) */
.yk-wxuser { display: flex; align-items: center; gap: 8px; }
.yk-wxuser__meta { min-width: 0; }
/* WhatsApp 文字行(员工信息 / 员工WA / 客户WA 复合单元格)— 与 StaffList 一致 */
.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; }
</style>