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>
This commit is contained in:
浅念
2026-08-01 17:24:38 +08:00
parent 0df53c6b1a
commit e5af1a2bc2
5 changed files with 588 additions and 25 deletions
+10 -3
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
from typing import Generic, TypeVar from typing import Any, Generic, TypeVar
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -13,8 +13,10 @@ T = TypeVar("T")
class CustomerOut(BaseModel): class CustomerOut(BaseModel):
"""Public Customer view — a subset of :class:`app.db.models.Customer`. """Public Customer view — a subset of :class:`app.db.models.Customer`.
Only the fields the viewing frontend needs today are exposed; the verbatim The structured columns power the list table; the verbatim upstream record
``raw_json`` blob and internal audit columns stay server-side. is exposed as ``raw_json`` so the frontend can render the full ~60-field
云客 客户详情 (all-fields drawer) without a second endpoint. The model's
``raw_json`` column is picked up automatically via ``from_attributes``.
""" """
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -33,6 +35,11 @@ class CustomerOut(BaseModel):
last_chat_time: int | None = None last_chat_time: int | None = None
message_count: int | None = None message_count: int | None = None
version: str | None = None version: str | None = None
# Verbatim upstream friend record (~60 camelCase fields). Surfaced so the
# all-fields drawer can render every column the 云客 source page shows
# (friendWxAlias/addTime/createTime/fromType/addType/...) without the
# backend having to enumerate each one as a structured column.
raw_json: dict[str, Any] | None = None
class Page(BaseModel, Generic[T]): class Page(BaseModel, Generic[T]):
+40 -1
View File
@@ -56,6 +56,16 @@ async def client():
last_chat_time=2000, last_chat_time=2000,
message_count=10, message_count=10,
version="v1", version="v1",
# Verbatim upstream record — the all-fields drawer reads
# these camelCase keys off raw_json.
raw_json={
"friendWxId": "wx_a",
"friendWxAlias": "alice_wx",
"friendWxNickname": "Alice",
"saleName": "Tom",
"addTime": 1690000000000,
"fromType": "qrcode",
},
), ),
Customer( Customer(
friend_id="wx_b", friend_id="wx_b",
@@ -65,6 +75,7 @@ async def client():
friend_nickname="Bob", friend_nickname="Bob",
last_chat_time=1000, last_chat_time=1000,
version="v1", version="v1",
raw_json=None,
), ),
Customer( Customer(
friend_id="wa_c", friend_id="wa_c",
@@ -112,7 +123,8 @@ async def test_list_default_page_returns_all(client: AsyncClient):
assert body["page_size"] == 20 assert body["page_size"] == 20
assert body["total"] == 3 assert body["total"] == 3
assert len(body["items"]) == 3 assert len(body["items"]) == 3
# Shape: every documented CustomerOut field is present. # Shape: every documented CustomerOut field is present, including the
# verbatim upstream record exposed for the all-fields drawer.
item = body["items"][0] item = body["items"][0]
for key in ( for key in (
"friend_id", "friend_id",
@@ -129,10 +141,37 @@ async def test_list_default_page_returns_all(client: AsyncClient):
"last_chat_time", "last_chat_time",
"message_count", "message_count",
"version", "version",
"raw_json",
): ):
assert key in item assert key in item
async def test_raw_json_serializes_verbatim(client: AsyncClient):
"""``raw_json`` round-trips the verbatim upstream record unmolested.
Seeded ``wx_a`` carries a populated blob; ``wx_b``/``wa_c`` have NULL
raw_json (omitted on the model). Both must serialize: present dict vs null.
"""
r = await client.get("/api/customers", params={"channel": "wechat"})
assert r.status_code == 200
by_id = {it["friend_id"]: it for it in r.json()["items"]}
# Populated blob: every camelCase key preserved verbatim.
alice = by_id["wx_a"]
assert alice["raw_json"] == {
"friendWxId": "wx_a",
"friendWxAlias": "alice_wx",
"friendWxNickname": "Alice",
"saleName": "Tom",
"addTime": 1690000000000,
"fromType": "qrcode",
}
# NULL raw_json serializes as JSON null (not omitted, not an error).
bob = by_id["wx_b"]
assert bob["raw_json"] is None
async def test_list_filters_by_channel_wechat(client: AsyncClient): async def test_list_filters_by_channel_wechat(client: AsyncClient):
"""``?channel=wechat`` -> total 2, both wechat rows.""" """``?channel=wechat`` -> total 2, both wechat rows."""
r = await client.get( r = await client.get(
+7
View File
@@ -32,6 +32,13 @@ export interface Customer {
last_chat_time?: number | null last_chat_time?: number | null
message_count?: number | null message_count?: number | null
version?: string | null version?: string | null
/**
* Verbatim upstream friend record (~60 camelCase fields, see 云客 data
* dictionary). Surfaced so the all-fields drawer can render every column
* the source page shows without the backend enumerating each as a
* structured column. `null` when the row was seeded without a blob.
*/
raw_json?: Record<string, unknown> | null
} }
export interface Department { export interface Department {
+275
View File
@@ -0,0 +1,275 @@
/**
* Chinese-label map + grouping for the Customer all-fields drawer.
*
* The 云客 upstream friend record (`Customer.raw_json`) carries ~60 camelCase
* fields (see docs/superpowers/specs/2026-08-01-yunke-data-dictionary.md §2).
* This module turns that blob into a labeled, grouped, render-ready structure
* for the drawer, with structured columns preferred over the verbatim value
* when both exist (the structured column is type-normalized on ingest).
*
* Unknown keys (anything not in the label map) fall back to the raw key, so a
* new field added upstream still shows up in 其他 instead of disappearing.
*/
import type { Customer } from '@/api/types'
import { formatTime } from './format'
/** camelCase raw_json key → 中文 label. Missing keys fall back to the key. */
export const CUSTOMER_FIELD_LABELS: Record<string, string> = {
// 基本信息
friendWxId: '客户微信ID',
friendWxNickname: '微信昵称',
friendWxAlias: '微信号',
friendWxRemark: '备注',
alias: '微信号(归属)',
realName: '真实姓名',
gender: '性别',
region: '地区',
description: '描述',
friendHeadImageUrl: '头像',
headImg: '头像(备用)',
// 联系方式
phone: '手机号',
wechatPhone: '微信手机号',
friendWxPhone: '好友手机号',
// 归属员工与部门
saleName: '销售员',
userName: '员工姓名',
userId: '员工ID',
wxId: '归属微信ID',
ownerWchatId: '归属微信号',
deptName: '部门',
deptRouteName: '部门路径',
deptRoute: '部门路径编码',
// 时间与来源
lastChatTime: '最近聊天时间',
createTime: '创建时间',
addTime: '添加时间',
addType: '添加方式',
fromType: '客户来源',
// 消息统计
sendNum: '发送消息数',
receiveNum: '接收消息数',
messageCount: '消息总数',
// 好友状态
friendState: '好友状态',
friendType: '好友类型',
friendTypeYunke: '云客好友类型',
deleteFlag: '删除标记',
isBlock: '是否拉黑',
isReFriend: '是否重复好友',
isQyFriend: '是否企微好友',
isSale: '是否销售好友',
// 其他 / 内部
version: '版本号',
companyCode: '公司编码',
encryptUsername: '加密用户名',
friendWxNicknameReverse: '昵称倒排(检索用)',
verifyFlag: '验证标记',
}
/**
* raw_json camelCase key → the structured snake_case column that mirrors it.
* When a structured column is populated we prefer it (type-normalized); we
* fall back to the verbatim raw_json value otherwise.
*/
const STRUCTURED_ALIAS: Record<string, keyof Customer> = {
friendWxId: 'friend_id',
friendWxNickname: 'friend_nickname',
friendWxRemark: 'friend_remark',
realName: 'real_name',
phone: 'phone',
region: 'region',
saleName: 'sale_name',
deptName: 'dept_name',
friendHeadImageUrl: 'head_image_url',
lastChatTime: 'last_chat_time',
messageCount: 'message_count',
version: 'version',
}
/** Fields whose value is an image URL → render as a thumbnail. */
const IMAGE_KEYS = new Set(['friendHeadImageUrl', 'headImg'])
/** Fields whose value is a millisecond epoch → format to local datetime. */
const TIMESTAMP_KEYS = new Set(['lastChatTime', 'createTime', 'addTime'])
/** Fields whose value is a gender code (0/1/2). */
const GENDER_KEYS = new Set(['gender'])
/** Fields whose value is a boolean / 0|1 flag → render 是/否. */
const BOOLEAN_KEYS = new Set([
'isBlock',
'isReFriend',
'isQyFriend',
'isSale',
])
/** Drawer sections, rendered in this order. */
export interface CustomerFieldGroup {
title: string
/** camelCase raw_json keys to surface in this section. */
keys: string[]
}
export const CUSTOMER_FIELD_GROUPS: CustomerFieldGroup[] = [
{
title: '基本信息',
keys: [
'friendWxId',
'friendWxNickname',
'friendWxAlias',
'friendWxRemark',
'realName',
'gender',
'region',
'description',
'friendHeadImageUrl',
'headImg',
],
},
{
title: '联系方式',
keys: ['phone', 'wechatPhone', 'friendWxPhone'],
},
{
title: '归属员工与部门',
keys: [
'saleName',
'userName',
'userId',
'wxId',
'ownerWchatId',
'deptName',
'deptRouteName',
'deptRoute',
],
},
{
title: '时间与来源',
keys: ['lastChatTime', 'createTime', 'addTime', 'addType', 'fromType'],
},
{
title: '消息统计',
keys: ['sendNum', 'receiveNum', 'messageCount'],
},
{
title: '好友状态',
keys: [
'friendState',
'friendType',
'friendTypeYunke',
'deleteFlag',
'isBlock',
'isReFriend',
'isQyFriend',
'isSale',
],
},
]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(CUSTOMER_FIELD_GROUPS.flatMap((g) => g.keys))
/** Resolve the 中文 label for a raw_json key (falls back to the key). */
export function fieldLabel(key: string): string {
return CUSTOMER_FIELD_LABELS[key] ?? key
}
/**
* Read a field value with structured-column preference.
* Returns the verbatim raw_json value when no structured column maps.
*/
export function readField(c: Customer, camelKey: string): unknown {
const alias = STRUCTURED_ALIAS[camelKey]
if (alias) {
const v = c[alias]
if (v !== null && v !== undefined) return v
}
return c.raw_json?.[camelKey]
}
/** A single field prepared for drawer rendering. */
export interface DisplayField {
key: string
label: string
/** Raw value (for text fallback). */
value: unknown
/** How the template should render it. */
render: 'image' | 'time' | 'gender' | 'bool' | 'text' | 'empty'
/** Pre-formatted text (for time/gender/bool/text kinds). Empty for image/empty. */
text: string
}
/** gender code (0/1/2) → label. Other codes pass through as `性别 N`. */
export function genderLabel(v: unknown): string {
if (v === 0 || v === '0') return '未知'
if (v === 1 || v === '1') return '男'
if (v === 2 || v === '2') return '女'
return `性别 ${String(v)}`
}
/** 0|1|true|false → 是/否; other ints pass through so info isn't hidden. */
export function yesNoLabel(v: unknown): string {
if (v === true || v === 1 || v === '1') return '是'
if (v === false || v === 0 || v === '0') return '否'
return String(v)
}
/** Classify + format a single field for the drawer. */
export function toDisplayField(c: Customer, camelKey: string): DisplayField {
const value = readField(c, camelKey)
const label = fieldLabel(camelKey)
if (value === null || value === undefined || value === '') {
return { key: camelKey, label, value, render: 'empty', text: '' }
}
if (IMAGE_KEYS.has(camelKey) && typeof value === 'string') {
return { key: camelKey, label, value, render: 'image', text: '' }
}
if (TIMESTAMP_KEYS.has(camelKey) && typeof value === 'number') {
return { key: camelKey, label, value, render: 'time', text: formatTime(value) }
}
if (GENDER_KEYS.has(camelKey)) {
return { key: camelKey, label, value, render: 'gender', text: genderLabel(value) }
}
if (BOOLEAN_KEYS.has(camelKey)) {
return { key: camelKey, label, value, render: 'bool', text: yesNoLabel(value) }
}
return { key: camelKey, label, value, render: 'text', text: String(value) }
}
/**
* Build the grouped display model for a customer.
*
* Each known group only includes fields that have a value (empty ones are
* dropped to keep the drawer compact). A trailing 其他 section collects every
* raw_json key not already surfaced, so new upstream fields show up
* automatically instead of being silently hidden.
*/
export interface CustomerFieldSection {
title: string
fields: DisplayField[]
}
export function buildCustomerSections(c: Customer): CustomerFieldSection[] {
const sections: CustomerFieldSection[] = CUSTOMER_FIELD_GROUPS.map((g) => ({
title: g.title,
fields: g.keys
.map((k) => toDisplayField(c, k))
.filter((f) => f.render !== 'empty'),
}))
// 其他: every raw_json key not already shown, in original insertion order.
const raw = c.raw_json ?? {}
const others: DisplayField[] = []
for (const key of Object.keys(raw)) {
if (KNOWN_KEYS.has(key)) continue
const f = toDisplayField(c, key)
if (f.render === 'empty') continue
others.push(f)
}
if (others.length > 0) {
sections.push({ title: '其他字段', fields: others })
}
return sections.filter((s) => s.fields.length > 0)
}
+256 -21
View File
@@ -7,6 +7,11 @@ import type { Channel, Customer } from '@/api/types'
import { useChannelStore } from '@/stores/channel' import { useChannelStore } from '@/stores/channel'
import { useOrgStore } from '@/stores/org' import { useOrgStore } from '@/stores/org'
import { channelLabel, formatTime } from '@/utils/format' import { channelLabel, formatTime } from '@/utils/format'
import {
buildCustomerSections,
genderLabel,
readField,
} from '@/utils/customerFields'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -20,6 +25,10 @@ const pageSize = ref(20)
const loading = ref(false) const loading = ref(false)
const errorMsg = ref<string | null>(null) 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>(() => const departmentId = computed<string | null>(() =>
route.query.department_id ? String(route.query.department_id) : null, route.query.department_id ? String(route.query.department_id) : null,
) )
@@ -35,7 +44,9 @@ const channelOptions = [
{ value: 'whatsapp' as const, label: 'WhatsApp' }, { value: 'whatsapp' as const, label: 'WhatsApp' },
] ]
const channelLabelCurrent = computed(() => channelLabel(channelStore.channel === 'all' ? null : channelStore.channel)) const channelLabelCurrent = computed(() =>
channelLabel(channelStore.channel === 'all' ? null : channelStore.channel),
)
function channelTagType(c: Customer['channel']) { function channelTagType(c: Customer['channel']) {
return c === 'wechat' ? 'success' : 'warning' return c === 'wechat' ? 'success' : 'warning'
@@ -45,6 +56,37 @@ function onChannelChange(value: string | number | boolean | undefined): void {
channelStore.setChannel(value as Channel | 'all') 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> { async function load(): Promise<void> {
loading.value = true loading.value = true
errorMsg.value = null errorMsg.value = null
@@ -85,9 +127,28 @@ watch(
}, },
) )
function openChat(row: Customer): void { function openDrawer(row: Customer): void {
// The chat timeline is keyed by (channel, owner_account_id, talker_id). // Row click opens the all-fields drawer; chat is reached from the drawer's
// The customer's friend_id is the talker on the staff's side of the DM. // 查看聊天记录 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) { if (!row.owner_account_id) {
ElMessage.warning('该客户缺少归属账号,无法查看聊天记录') ElMessage.warning('该客户缺少归属账号,无法查看聊天记录')
return return
@@ -160,47 +221,77 @@ onMounted(load)
style="width: 100%" style="width: 100%"
:row-class-name="() => 'yk-clickable-row'" :row-class-name="() => 'yk-clickable-row'"
empty-text="暂无客户数据" empty-text="暂无客户数据"
@row-click="openChat" @row-click="openDrawer"
> >
<el-table-column label="头像" width="64" align="center"> <el-table-column label="头像" width="64" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-avatar <el-avatar
:size="36" :size="36"
:src="row.head_image_url ?? undefined" :src="imageCell(row, 'friendHeadImageUrl') ?? row.head_image_url ?? undefined"
shape="square" shape="square"
> >
{{ (row.friend_nickname || row.friend_id || '?').slice(0, 1) }} {{ (row.friend_nickname || row.friend_id || '?').slice(0, 1) }}
</el-avatar> </el-avatar>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="昵称" prop="friend_nickname" min-width="120"> <el-table-column label="微信昵称" min-width="120">
<template #default="{ row }"> <template #default="{ row }">
{{ row.friend_nickname || '--' }} {{ cellText(row, 'friendWxNickname') }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="备注" min-width="120"> <el-table-column label="微信号" min-width="110">
<template #default="{ row }">{{ row.friend_remark || '--' }}</template> <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>
<el-table-column label="真实姓名" min-width="100"> <el-table-column label="真实姓名" min-width="100">
<template #default="{ row }">{{ row.real_name || '--' }}</template> <template #default="{ row }">{{ cellText(row, 'realName') }}</template>
</el-table-column> </el-table-column>
<el-table-column label="手机" min-width="120"> <el-table-column label="手机" min-width="120">
<template #default="{ row }">{{ row.phone || '--' }}</template> <template #default="{ row }">{{ cellText(row, 'phone') }}</template>
</el-table-column> </el-table-column>
<el-table-column label="渠道" width="100"> <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 }"> <template #default="{ row }">
<el-tag :type="channelTagType(row.channel)" size="small" effect="plain"> <span v-if="cell(row, 'saleName') || cell(row, 'userName')">
{{ channelLabel(row.channel) }} {{ cellText(row, 'saleName') }}
</el-tag> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="部门" min-width="110"> <el-table-column label="部门" min-width="110">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.dept_name">{{ row.dept_name }}</span> <span v-if="cell(row, 'deptName')">{{ cellText(row, 'deptName') }}</span>
<span v-else style="color: #c0c4cc">--</span> <span v-else style="color: #c0c4cc">--</span>
<div v-if="row.sale_name" style="font-size: 12px; color: #909399"> </template>
销售:{{ row.sale_name }} </el-table-column>
</div> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="最近聊天" width="160"> <el-table-column label="最近聊天" width="160">
@@ -213,6 +304,13 @@ onMounted(load)
{{ row.message_count ?? 0 }} {{ row.message_count ?? 0 }}
</template> </template>
</el-table-column> </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> </el-table>
<div class="yk-customers__pager"> <div class="yk-customers__pager">
@@ -227,7 +325,71 @@ onMounted(load)
@size-change="handlePageSizeChange" @size-change="handlePageSizeChange"
/> />
</div> </div>
<p class="yk-customers__hint">点击任意一行查看与该客户的聊天记录。</p> <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> </div>
</template> </template>
@@ -264,4 +426,77 @@ onMounted(load)
font-size: 12px; font-size: 12px;
color: #c0c4cc; 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> </style>