Files
yunke/yunkes/src/views/CustomerList.vue
T
浅念 dfdd0595d2 feat(staff): action buttons → filtered customer/group list
Adds groups.user_id filter; viewCustomers/viewGroups push owner_account_id query; CustomerList/GroupList read it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 19:06:27 +08:00

525 lines
15 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, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getCustomers } from '@/api'
import type { Channel, Customer } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { useOrgStore } from '@/stores/org'
import { channelLabel, formatTime } from '@/utils/format'
import {
buildCustomerSections,
genderLabel,
readField,
} from '@/utils/customerFields'
const route = useRoute()
const router = useRouter()
const channelStore = useChannelStore()
const org = useOrgStore()
const customers = ref<Customer[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// All-fields drawer state.
const drawerOpen = ref(false)
const drawerCustomer = ref<Customer | null>(null)
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
})
const channelOptions = [
{ value: 'all' as const, label: '全部渠道' },
{ value: 'wechat' as const, label: '微信' },
{ value: 'whatsapp' as const, label: 'WhatsApp' },
]
const channelLabelCurrent = computed(() =>
channelLabel(channelStore.channel === 'all' ? null : channelStore.channel),
)
function channelTagType(c: Customer['channel']) {
return c === 'wechat' ? 'success' : 'warning'
}
function onChannelChange(value: string | number | boolean | undefined): void {
channelStore.setChannel(value as Channel | 'all')
}
/**
* Read a raw_json camelCase key off a customer, preferring the structured
* snake_case column when it is populated. The first argument is the row so the
* table can use the same helper as the drawer.
*/
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)
}
/** Image URL for an avatar/head column: a string value, else undefined. */
function imageCell(row: Customer, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
/** Numeric ms-epoch for a time column (so formatTime stays type-safe). */
function timeCell(row: Customer, camelKey: string): number | undefined {
const v = cell(row, camelKey)
return typeof v === 'number' ? v : undefined
}
function genderText(row: Customer): string {
const v = cell(row, 'gender')
return v === null || v === undefined || v === '' ? '--' : genderLabel(v)
}
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getCustomers({
channel: channelStore.channel,
page: page.value,
page_size: pageSize.value,
department_id: departmentId.value ?? undefined,
owner_account_id: ownerId.value ?? undefined,
})
customers.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`客户列表加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
/** Drop the ``owner_account_id`` query param while preserving any others. */
function clearOwnerFilter(): void {
const { owner_account_id: _drop, ...rest } = route.query
void _drop
router.replace({ query: rest })
}
// React to top-bar channel + dept tree selection + owner_account_id query.
watch(
() => [channelStore.channel, departmentId.value, ownerId.value] as const,
() => {
page.value = 1
load()
},
)
function openDrawer(row: Customer): void {
// Row click opens the all-fields drawer; chat is reached from the drawer's
// 查看聊天记录 action (preserves the owner/friend/channel keying).
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
return imageCell(c, 'friendHeadImageUrl') ?? c.head_image_url ?? undefined
})
const drawerCanChat = computed(() => !!drawerCustomer.value?.owner_account_id)
function goChatFromDrawer(): void {
const row = drawerCustomer.value
if (!row) return
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,
},
})
}
onMounted(load)
</script>
<template>
<div class="yk-customers">
<div class="yk-customers__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">客户列表</h2>
<span class="yk-customers__sub">
共 {{ total }} 条
<template v-if="departmentName"> · 部门:{{ departmentName }}</template>
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
· 渠道:{{ channelLabelCurrent }}
</span>
</div>
<div class="yk-customers__filters">
<el-radio-group
:model-value="channelStore.channel"
size="small"
@change="onChannelChange"
>
<el-radio-button
v-for="opt in channelOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
<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>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="customers"
stripe
border
row-key="friend_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无客户数据"
@row-click="openDrawer"
>
<el-table-column label="头像" width="64" align="center">
<template #default="{ row }">
<el-avatar
:size="36"
:src="imageCell(row, 'friendHeadImageUrl') ?? row.head_image_url ?? undefined"
shape="square"
>
{{ (row.friend_nickname || row.friend_id || '?').slice(0, 1) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column label="微信昵称" min-width="120">
<template #default="{ row }">
{{ cellText(row, 'friendWxNickname') }}
</template>
</el-table-column>
<el-table-column label="微信号" min-width="110">
<template #default="{ row }">
{{ cellText(row, 'friendWxAlias') }}
</template>
</el-table-column>
<el-table-column label="备注" min-width="110">
<template #default="{ row }">{{ cellText(row, 'friendWxRemark') }}</template>
</el-table-column>
<el-table-column label="真实姓名" min-width="100">
<template #default="{ row }">{{ cellText(row, 'realName') }}</template>
</el-table-column>
<el-table-column label="手机" min-width="120">
<template #default="{ row }">{{ cellText(row, 'phone') }}</template>
</el-table-column>
<el-table-column label="性别" width="80">
<template #default="{ row }">{{ genderText(row) }}</template>
</el-table-column>
<el-table-column label="地区" min-width="110">
<template #default="{ row }">{{ cellText(row, 'region') }}</template>
</el-table-column>
<el-table-column label="描述" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ cellText(row, 'description') }}</template>
</el-table-column>
<el-table-column label="销售/员工" min-width="120">
<template #default="{ row }">
<span v-if="cell(row, 'saleName') || cell(row, 'userName')">
{{ cellText(row, 'saleName') }}
<span
v-if="cell(row, 'saleName') && cell(row, 'userName')"
style="color: #c0c4cc"
>
/
</span>
{{ cellText(row, 'userName') }}
</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="部门" min-width="110">
<template #default="{ row }">
<span v-if="cell(row, 'deptName')">{{ cellText(row, 'deptName') }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="来源" min-width="100">
<template #default="{ row }">{{ cellText(row, 'fromType') }}</template>
</el-table-column>
<el-table-column label="添加方式" min-width="100">
<template #default="{ row }">{{ cellText(row, 'addType') }}</template>
</el-table-column>
<el-table-column label="添加时间" width="160">
<template #default="{ row }">
{{ formatTime(timeCell(row, 'addTime')) }}
</template>
</el-table-column>
<el-table-column label="最近聊天" width="160">
<template #default="{ row }">
{{ formatTime(row.last_chat_time) }}
</template>
</el-table-column>
<el-table-column label="消息数" prop="message_count" width="90" align="right">
<template #default="{ row }">
{{ row.message_count ?? 0 }}
</template>
</el-table-column>
<el-table-column label="渠道" width="100">
<template #default="{ row }">
<el-tag :type="channelTagType(row.channel)" size="small" effect="plain">
{{ channelLabel(row.channel) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="yk-customers__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-customers__hint">点击任意一行查看客户全部字段。</p>
<!-- All-fields drawer -->
<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="goChatFromDrawer"
>
查看聊天记录
</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')">
· {{ cell(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>
</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-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>