diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index 9b6ba05..d4c2ba1 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations import datetime as dt -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar from pydantic import BaseModel, ConfigDict @@ -13,8 +13,10 @@ T = TypeVar("T") class CustomerOut(BaseModel): """Public Customer view — a subset of :class:`app.db.models.Customer`. - Only the fields the viewing frontend needs today are exposed; the verbatim - ``raw_json`` blob and internal audit columns stay server-side. + The structured columns power the list table; the verbatim upstream record + 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) @@ -33,6 +35,11 @@ class CustomerOut(BaseModel): last_chat_time: int | None = None message_count: int | 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]): diff --git a/yunke-server/tests/test_api_customer.py b/yunke-server/tests/test_api_customer.py index 8b546e4..dda7be3 100644 --- a/yunke-server/tests/test_api_customer.py +++ b/yunke-server/tests/test_api_customer.py @@ -56,6 +56,16 @@ async def client(): last_chat_time=2000, message_count=10, 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( friend_id="wx_b", @@ -65,6 +75,7 @@ async def client(): friend_nickname="Bob", last_chat_time=1000, version="v1", + raw_json=None, ), Customer( 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["total"] == 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] for key in ( "friend_id", @@ -129,10 +141,37 @@ async def test_list_default_page_returns_all(client: AsyncClient): "last_chat_time", "message_count", "version", + "raw_json", ): 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): """``?channel=wechat`` -> total 2, both wechat rows.""" r = await client.get( diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index a036f61..b40fae3 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -32,6 +32,13 @@ export interface Customer { last_chat_time?: number | null message_count?: number | 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 | null } export interface Department { diff --git a/yunkes/src/utils/customerFields.ts b/yunkes/src/utils/customerFields.ts new file mode 100644 index 0000000..3baa40b --- /dev/null +++ b/yunkes/src/utils/customerFields.ts @@ -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 = { + // 基本信息 + 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 = { + 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) +} diff --git a/yunkes/src/views/CustomerList.vue b/yunkes/src/views/CustomerList.vue index 6607e42..5c7553e 100644 --- a/yunkes/src/views/CustomerList.vue +++ b/yunkes/src/views/CustomerList.vue @@ -7,6 +7,11 @@ 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() @@ -20,6 +25,10 @@ const pageSize = ref(20) const loading = ref(false) const errorMsg = ref(null) +// All-fields drawer state. +const drawerOpen = ref(false) +const drawerCustomer = ref(null) + const departmentId = computed(() => route.query.department_id ? String(route.query.department_id) : null, ) @@ -35,7 +44,9 @@ const channelOptions = [ { 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']) { return c === 'wechat' ? 'success' : 'warning' @@ -45,6 +56,37 @@ 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 { loading.value = true errorMsg.value = null @@ -85,9 +127,28 @@ watch( }, ) -function openChat(row: Customer): void { - // The chat timeline is keyed by (channel, owner_account_id, talker_id). - // The customer's friend_id is the talker on the staff's side of the DM. +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(() => { + 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 @@ -160,47 +221,77 @@ onMounted(load) style="width: 100%" :row-class-name="() => 'yk-clickable-row'" empty-text="暂无客户数据" - @row-click="openChat" + @row-click="openDrawer" > - + - - + + + + + - + - + - + + + + + + + + + + + + + + + + + + + @@ -213,6 +304,13 @@ onMounted(load) {{ row.message_count ?? 0 }} + + +
@@ -227,7 +325,71 @@ onMounted(load) @size-change="handlePageSizeChange" />
-

点击任意一行查看与该客户的聊天记录。

+

点击任意一行查看客户全部字段。

+ + + + + @@ -264,4 +426,77 @@ onMounted(load) 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; +}