Files
yunke/docs/superpowers/plans/2026-08-06-yunke-customerlist-alignment.md
T
浅念 c31d79f1e2 docs: Plan 2 — CustomerList 对齐云客(采集 + TDD plan)
phase0 采集云客「沟通」板块客户列表(wechat wechatdetail/1 + waBusiness/friendlist),
dump raw_json 字段覆盖,产出列+交互+字段映射;写 Plan 2(分 channel tab 各 7 列 +
查看聊天记录→ChatTimeline),经 plan-reviewer 评审 Approved。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 10:12:36 +08:00

663 lines
31 KiB
Markdown
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.
# CustomerList 列结构 + 交互对齐云客 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 CustomerList(客户列表)对齐云客「沟通」板块的客户列表——分 微信客户 / WhatsApp客户 两个 tab,各自复刻云客的 7 列布局与「查看聊天记录」交互,与已完成的 StaffList 同款模式。
**Architecture:** 纯前端改造(后端能力 Plan 1 已备齐:customer 筛选 `owner_account_id` / `user_id` / `add_time_since` / `last_chat_since` + `add_time` 列)。`CustomerList.vue` 从「统一 17 列 + channel radio」重构为「`el-tabs` 分 channel + 每个 channel 独立 7 列 + 操作列跳 ChatTimeline」(StaffList 同款)。复用 `readField` / `cellTimeText` / `yk-pair` / `yk-staffinfo` / drawer;新增 `fromType` 代码→文案映射。
**Tech Stack:** Vue3 + TS + Element Plus + Pinia(前端)。**无后端改动**。
**Spec:** `docs/superpowers/specs/2026-08-05-yunke-ui-alignment-design.md`
**参考(模式来源):** `docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md`(Plan 1,已完成;CustomerList 照搬其 el-tabs 分 channel + 复合单元格 + drawer 模式)
---
## 已采集事实(Playwright 验证,2026-08-06,crm2.yunkecn.com)
**微信客户列表** `/cms/wechat/wechatlist/wechatdetail/1/{wxId}?ymdStart=0000-00-00&ymdEnd=YYYY-MM-DD`(title「微信客户」,tabs 微信客户/微信群):
- 列(7):客户微信(昵称 `friendWxNickname` + wxId `friendWxId`) / 数据来源(`fromType` 代码→文案,`1000014`→工作手机) / 客户备注名(`friendWxRemark`) / 最近沟通时间(`lastChatTime`) / 发送条数(`sendNum`) / 接收条数(`receiveNum`) / 操作
- 交互:**仅操作列可点**——「查看聊天记录」→ 新 tab `/cms/wechat/detail/message/{ownerWxId}/{friendWxId}/1?lastChatTime=...`;「导出HTML聊天记录」→ **不复刻**(spec:不引入装饰按钮)
- 昵称 / 数字列**均不可点**(纯展示)—— 与 StaffList(数字可点 modal)不同,客户列表数字就是简单计数
- 字段覆盖(9 行):6/7 全覆盖,`friendWxRemark` 2/9(多数空→显示 `--`);`fromType` 取值 `1000014` / `14` / `15`
**WhatsApp 客户列表** `/cms/wechat/waBusiness/friendlist`(title「客户列表」,tabs 好友列表/群列表):
- 列(7):序号 / 员工信息(`salesNickName` + `salePhone`) / 员工WhatsApp(`userName` + `uid`) / 客户WhatsApp(`nickName` + `phone`) / 添加时间(`createTime`) / 最新消息时间(`lastUpdate`) / 操作
- 交互:**仅操作列可点**——「查看聊天记录」→ 新 tab 聊天
- 字段覆盖(10 行):`salesNickName`/`salePhone`/`userName`/`uid`/`nickName`/`phone`/`createTime`/`lastUpdate` 齐全
- 注:`createTime` 字符串 `"YYYY-MM-DD HH:mm"`,云客作「客户添加时间」显示(实现时用 `cellTimeText` 直显字符串;与 Plan 1 Task 2「createTime 是 owner-staff 字段、不填 add_time」不冲突——这里只是展示该列,不参与 add_time_since 筛选)
**关键差异(决策依据):** yunkes 现状 CustomerList 是统一 17 列 + channel radio;云客是每 channel 极简 7 列且两 channel 列不同。按 spec「列对齐云客」+ Plan 1 StaffList 已验证的「分 tab + 各自独立列」模式,重构为分 tab(**用户已确认「严格对齐云客」方向**)。yunkes 现状其余字段保留在 drawer(buildCustomerSections 不变)。
**运行命令(验证用):**
- 前端:`cd yunkes && npm run dev`
- 前端类型:`cd yunkes && npx vue-tsc -b`(exit 0)
- 后端(验证不受影响):`cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000 --log-level warning`
- 后端测试:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q`(应仍 ≥330 绿;本 plan 无后端改动)
---
## File Map
**前端(yunkes/src/):**
- Modify `utils/customerFields.ts` — 新增 `fromTypeLabel()` helper(`sendNum`/`receiveNum` 走 raw_json fallback,**不改 `STRUCTURED_ALIAS`**:`Customer` 类型 / `CustomerOut` 未暴露 `send_num`/`receive_num`)
- Modify `views/CustomerList.vue` — 重构:统一 17 列 → `el-tabs` 分 channel(微信客户 / WhatsApp客户),各自 7 列 + 操作列 `viewChat` + `owner_account_id` query 预筛 + drawer 保留
**不改动:** 后端(Plan 1 已备齐)、router、`api/index.ts`(`getCustomers` 已支持所需参数)、`YkStaffDetailModal`(客户列表数字不可点,无 modal)
---
## Task 1: customerFields.ts 扩展(fromTypeLabel helper)
**Files:**
- Modify: `yunkes/src/utils/customerFields.ts`
- [ ] **Step 1: 不修改 `STRUCTURED_ALIAS`**(`sendNum`/`receiveNum` 走 `readField` 的 raw_json fallback)。理由:`STRUCTURED_ALIAS` 类型是 `Record<string, keyof Customer>`,而 `Customer` 类型(`api/types.ts`)+ 后端 `CustomerOut`(`app/api/schemas.py`)都不暴露 `send_num`/`receive_num` 结构化列(DB 有列但未上 API),加 alias 会让 `vue-tsc -b` 报 `'"send_num"' is not assignable to type 'keyof Customer'`。`sendNum`/`receiveNum` 在 raw_json 以 camelCase 存在,`readField` 的 fallback 路径能拿到 —— 同 StaffList/AccountList 已验证模式(其 `STRUCTURED_ALIAS` 也无这俩条目)。**本 Task 仅做 Step 2。**
- [ ] **Step 2: 新增 `fromTypeLabel` helper**(文件底部,`yesNoLabel` 下方):
```ts
/**
* 云客 `fromType` 代码 → 中文文案(用于「数据来源」列)。
* 已知:1000014→工作手机。其余代码(如 '14'/'15')返回原值,避免隐藏;
* 如需补全,从云客界面按来源筛选采集后追加进 FROM_TYPE_LABELS。
*/
const FROM_TYPE_LABELS: Record<string, string> = {
'1000014': '工作手机',
}
export function fromTypeLabel(v: unknown): string {
if (v === null || v === undefined || v === '') return '--'
const s = String(v).trim()
return FROM_TYPE_LABELS[s] ?? s
}
```
- [ ] **Step 3: 类型检查**
```bash
cd yunkes && npx vue-tsc -b
```
预期:exit 0。
- [ ] **Step 4: Commit**
```bash
git add yunkes/src/utils/customerFields.ts
git commit -m "feat(customerFields): add fromTypeLabel helper"
```
---
## Task 2: CustomerList.vue 重写(分 tab + 两 channel 7 列 + 操作列 + query 预筛)
> 整文件重写(统一 17 列 → 分 tab 各自 7 列)。drawer 保留现状结构(avatar + buildCustomerSections + 查看聊天记录)。复用 StaffList 的 `tabState`/`loadTab`/`cellTimeText`/`yk-pair`/`yk-staffinfo` 模式。
**Files:**
- Modify: `yunkes/src/views/CustomerList.vue`(整文件替换为下方代码)
- [ ] **Step 1: 用以下内容整体替换 `CustomerList.vue`**
```vue
<script setup lang="ts">
import { computed, onMounted, reactive, 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, fromTypeLabel, readField } from '@/utils/customerFields'
/**
* 客户列表 — channel-tabbed view mirroring 云客「沟通」板块的客户列表.
*
* 微信客户 tab 对齐 云客 /cms/wechat/wechatlist/wechatdetail/1/{wxId}
* (7 列: 客户微信/数据来源/客户备注名/最近沟通时间/发送条数/接收条数/操作).
* 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: 20, 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>(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 {
const v = cell(row, 'fromType')
return v === null || v === undefined || v === '' ? '--' : fromTypeLabel(v)
}
function imageCell(row: Customer, camelKey: string): string | undefined {
const v = cell(row, camelKey)
return typeof v === 'string' && v !== '' ? v : undefined
}
/** 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) }
function onTabChange(name: string | number): void {
const ch = String(name) as TabKey
channelStore.setChannel(ch)
if (!tabState[ch].loaded) loadTab(ch)
}
// ---- 操作列:查看聊天记录 → 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)
}
/** 清除 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[activeTab.value].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">客户列表</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
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-tabs v-model="activeTab" class="yk-customers__tabs" @tab-change="onTabChange">
<!-- ============================================================
微信客户 — 7 列,对齐云客 wechatdetail/1/{wxId}.
============================================================ -->
<el-tab-pane label="微信客户" name="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="200">
<template #default="{ row }">
<div class="yk-pair">
<span class="yk-pair__k">昵称</span>
<span class="yk-pair__v" :title="cellText(row, 'friendWxNickname')">{{ cellText(row, 'friendWxNickname') }}</span>
</div>
<div class="yk-pair">
<span class="yk-pair__k">微信号</span>
<span class="yk-pair__v">{{ cellText(row, 'friendWxId') }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="数据来源" min-width="110">
<template #default="{ row }">{{ fromTypeText(row) }}</template>
</el-table-column>
<el-table-column label="客户备注名" min-width="120">
<template #default="{ row }">{{ cellText(row, 'friendWxRemark') }}</template>
</el-table-column>
<el-table-column label="最近沟通时间" width="170">
<template #default="{ row }">{{ cellTimeText(row, 'lastChatTime') }}</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="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.wechat.page"
:page-size="tabState.wechat.pageSize"
:page-sizes="[20, 50, 100]"
:total="tabState.wechat.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWechatPageChange"
@size-change="onWechatPageSizeChange"
/>
</div>
</el-tab-pane>
<!-- ============================================================
WhatsApp客户 — 7 列,对齐云客 waBusiness/friendlist.
============================================================ -->
<el-tab-pane label="WhatsApp客户" name="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="[20, 50, 100]"
:total="tabState.whatsapp.total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onWhatsappPageChange"
@size-change="onWhatsappPageSizeChange"
/>
</div>
</el-tab-pane>
</el-tabs>
<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>
</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__tabs { margin-top: 4px; }
.yk-customers__pager { margin-top: 12px; display: flex; justify-content: flex-end; }
.yk-customers__hint { margin: 8px 2px 0; font-size: 12px; color: #c0c4cc; }
/* label/value pair stack(客户微信 复合单元格)— 与 StaffList 一致 */
.yk-pair { display: flex; gap: 6px; line-height: 1.8; font-size: 13px; white-space: nowrap; }
.yk-pair__k { color: #909399; flex-shrink: 0; }
.yk-pair__v { color: #303133; overflow: hidden; text-overflow: ellipsis; }
/* 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>
```
- [ ] **Step 2: 类型检查**
```bash
cd yunkes && npx vue-tsc -b
```
预期:exit 0。常见报错排查:`fromTypeLabel`/`readField` 未导出 → 确认 Task 1 已加;`Customer` 上无某字段 → 核对 `api/types.ts`;drawer 已用 `viewChatFromDrawer` wrapper 规避 `drawerCustomer: Customer | null` 的窄化问题,**不要**用 `!`。
- [ ] **Step 3: Commit**
```bash
git add yunkes/src/views/CustomerList.vue
git commit -m "feat(customers): align CustomerList to 云客 — per-channel tabs, 7 cols each, view-chat action"
```
---
## Task 3: 端到端浏览器验证 + 收尾
> 需前后端都跑起来。后端无改动,仅提供数据。
- [ ] **Step 1: 起后端**(新终端)
```bash
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000 --log-level warning
```
- [ ] **Step 2: 起前端**
```bash
cd yunkes && npm run dev
```
- [ ] **Step 3: 浏览器验证 `/customers`**(对照采集的云客截图)
- 微信客户 tab:7 列(客户微信[昵称+微信号] / 数据来源 / 客户备注名 / 最近沟通时间 / 发送条数 / 接收条数 / 操作),数据正确填充(非整列 `--`);数据来源列 `1000014` 显示「工作手机」。
- WhatsApp客户 tab:7 列(序号 / 员工信息 / 员工WhatsApp / 客户WhatsApp / 添加时间 / 最新消息时间 / 操作),数据正确填充;添加时间/最新消息时间字符串正常显示。
- 切 tab:数据各自加载,互不串。
- 点「查看聊天记录」→ 跳 ChatTimeline 该客户会话。
- row-click → drawer 全字段(含被表格精简掉的字段:性别/地区/描述/真实姓名/部门/销售/添加时间(wechat)/消息数 等)。
- [ ] **Step 4: 验证 StaffList → CustomerList 预筛联动**
- `/staff` → 微信员工 tab 点某员工「好友数」→ 跳 `/customers?owner_account_id={wxId}`,自动落在**微信客户** tab、仅该员工客户、顶部「员工账号」chip + 清除按钮。
- `/staff` → WhatsApp员工 tab 点「好友列表」→ 跳 `/customers?owner_account_id={uid}`,落在**WhatsApp客户** tab + chip。
- 点「清除员工筛选」→ chip 消失、数据恢复全量。
- [ ] **Step 5: 后端回归(确认无影响)**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
```
预期:全 PASS(≥330;本 plan 无后端改动)。
- [ ] **Step 6: 截图留档**(微信客户 tab + WhatsApp客户 tab + drawer),更新本 plan 附录或 spec。
- [ ] **Step 7: Commit(如有微调)**
```bash
git add -A
git commit -m "test(customers): e2e verify CustomerList alignment vs 云客"
```
---
## 收尾验收清单
- [ ] `cd yunkes && npx vue-tsc -b` exit 0,无 console 报错。
- [ ] `/customers` 两 tab 列名/顺序/操作按钮与云客一致(截图留档)。
- [ ] 「查看聊天记录」行为与云客一致(跳 ChatTimeline)。
- [ ] 数据正确填充(raw_json 读字段,无整列 `--`);字符串时间正确显示。
- [ ] StaffList「好友数/好友列表」→ CustomerList 预筛 + chip + 清除,联动正确。
- [ ] drawer 仍展示全字段(精简列未丢信息)。
- [ ] 后端 pytest 仍 ≥330 绿(无后端改动)。
- [ ] API 抽查(数据保障):`curl -s "http://localhost:8000/api/customers?channel=wechat&page=1&page_size=3"` 返回的 `raw_json` 关键字段(`friendWxNickname`/`fromType`/`sendNum`/`receiveNum`/`lastChatTime`)大多非空 —— 这是表格「非整列 `--`」的底层保障。
---
## 采集附录 / 已知缺口
- **fromType 文案映射不全**:已知 `1000014`→工作手机;`14`/`15` 显示原代码。如需补全,从云客界面按来源筛选采集后追加 `FROM_TYPE_LABELS`。不阻塞(fallback 不隐藏信息)。
- **WA 客户 WhatsApp 列号码**:用 `phone`(如 `+233...`);云客显示纯号码(如 `584142462772`),`phone` 已是最佳映射。如要完全一致可改读 `friendUid` 去 `@s.whatsapp.net` 后缀,但 `phone` 更可读,保留。
- **WA「添加时间」= createTime**:按云客显示(字符串直显)。语义上 createTime 严格说是记录创建时间,与 Plan 1「不把 createTime 当 add_time 填充结构化列」不冲突——此处仅展示,不参与筛选。
- **不复刻**:导出HTML聊天记录按钮(spec:不引入装饰功能)。
---
## 后续 Plan(不在本 plan 范围)
- Plan 3: GroupList / CallList / SmsList(GroupList 同样从云客 `/wechatdetail/2/{wxId}` 与 WA 群列表采集后对齐)
- Plan 4: ChatTimeline 会话列表 + `/api/sessions` 聚合端点
- Plan 5: SearchView + AccountList 删除