Files
yunke/yunkes/src/views/CustomerList.vue
T
浅念 e5af1a2bc2 feat: customer full-field display (raw_json + cloud-matching list + all-fields drawer)
Backend:
- CustomerOut now exposes raw_json (verbatim upstream friend record) so the
  frontend can render every 云客 field without per-field structured columns.
- The verbatim blob round-trips via from_attributes; no model change needed.

Frontend:
- CustomerList table mirrors the 云客 客户列表 column set (头像/微信昵称/微信号/
  备注/真实姓名/手机/性别/地区/描述/销售员工/部门/来源/添加方式/添加时间/最近
  聊天/消息数/渠道). A readField helper reads the structured column first and
  falls back to raw_json.<camelCase>.
- Row click opens an all-fields drawer (right side) rendering EVERY field of
  the record with Chinese labels, grouped (基本信息/联系方式/归属员工与部门/时间
  与来源/消息统计/好友状态/其他). Timestamps format to local datetime, flags
  render 是/否, image URLs render as el-image thumbnails, empty values show —.
- New src/utils/customerFields.ts holds the camelCase→中文 label map, the
  structured-column alias map, group definitions, and value-type formatters;
  unknown raw_json keys auto-flow into an 其他 section so new upstream fields
  surface without code changes.
- Drawer 查看聊天记录 action navigates to /chat/:channel/:ownerId/:talkerId
  (owner_account_id + friend_id + channel), preserving the existing path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 17:24:38 +08:00

503 lines
14 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 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,
})
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()
}
// React to top-bar channel + dept tree selection.
watch(
() => [channelStore.channel, departmentId.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>
· 渠道:{{ 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>
</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>