Files
yunke/docs/superpowers/plans/2026-08-06-yunke-grouplist-alignment.md
T
浅念 de08bd09c7 docs: Plan 3 — GroupList 对齐云客(采集 + TDD plan)
phase0 采集云客「沟通」板块群列表(微信 wechatdetail/2 + WA friendlist 群列表 tab),
写 Plan 3(分 channel tab 各自列 + viewChat),经 plan-reviewer 评审 Approved。
已知 gap:smoke.db 0 群(空表验证)+ WA 群抓取缺失(字段推断)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 11:11:37 +08:00

608 lines
28 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.
# GroupList 列结构 + 交互对齐云客 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:** 把 GroupList(群列表)对齐云客「沟通」板块的群列表——分 微信群 / WhatsApp群 两个 tab,各自复刻云客列(wechat 7 列 / WA 8 列)+ 操作列「查看聊天记录」跳群聊天,与已完成的 CustomerList(Plan 2) 同款模式。
**Architecture:** 纯前端改造(后端 `getGroups` + `chat_group` 表已在;Plan 1 已加 groups `user_id` 筛选)。`GroupList.vue` 从「统一 8 列 + channel radio」重构为「`el-tabs` 分 channel + 每个 channel 独立列 + 操作列跳 group-chat」(CustomerList 同款)。复用 `groupFields`(`readField`/`buildGroupSections`) + `customerFields.fromTypeLabel`(Plan 2 已加) + `cellTimeText` / `yk-pair` / `yk-staffinfo` / drawer。
**Tech Stack:** Vue3 + TS + Element Plus + Pinia(前端)。**无后端改动**。
**Spec:** `docs/superpowers/specs/2026-08-05-yunke-ui-alignment-design.md`
**参考(模式来源):** `docs/superpowers/plans/2026-08-06-yunke-customerlist-alignment.md`(Plan 2,已完成;GroupList 照搬其 el-tabs 分 channel + 复合单元格 + drawer + viewChatFromDrawer 模式)
---
## 已采集事实(Playwright 验证,2026-08-06)
**微信群列表** `/cms/wechat/wechatlist/wechatdetail/2/{wxId}?ymdStart=0000-00-00&ymdEnd=YYYY-MM-DD`(title「微信群」,tabs 微信客户/微信群):
- 列(7):群信息(群名 `chatRoomName`) / 数据来源(`fromType`→文案) / 群主信息(昵称+wxId) / 群成员数量(`memberCount`) / 群成员好友数量(待核) / 消息数量(`messageCount`) / 操作
- 交互:**仅操作列可点**——「查看聊天记录」→ 新 tab 群聊天;「导出HTML聊天记录」→ **不复刻**
- 数字/群名/群主列**不可点**(纯展示,同客户列表)
- 字段确定性:`chatRoomName`/`memberCount`/`messageCount` ✅(scraper `_normalize_chat_group` + `groupFields.STRUCTURED_ALIAS` 确认);`fromType` / 群主昵称 / 群主 wxId / 群成员好友数量 ⚠️ **字段名待 smoke.db 有群数据核对**(见附录)
**WhatsApp 群列表** `/cms/wechat/waBusiness/friendlist` 的「群列表」tab(title「客户列表」,tabs 好友列表/群列表):
- 列(8):序号 / 员工信息(`salesNickName`+`salePhone`) / 员工WhatsApp(`userName`+`uid`) / 群信息(群名) / 群消息数量(`messageCount`?) / 添加时间(`createTime`?) / 最新消息时间(`lastUpdate`?) / 操作
- 交互:**仅操作列可点**——「查看聊天记录」→ 新 tab 群聊天
- ⚠️ **WA 群抓取缺失**(spec 真实验证未列 WA 群;`whatsapp.py` 只有 account 的 `group_num` 计数、无 group 实体抓取;`groupFields.ts` 只有 wechat 群字段):WA 群 tab 字段全为从云客采集 + WA customer/account 类比**推断**,无 raw_json/数据支撑,**UI 将空表**。待 WA 群抓取实现后核对字段。
**关键差异(决策依据):** yunkes 现状 GroupList 是统一 8 列 + channel radio;云客是每 channel 群列表列不同(wechat 7 / WA 8)。按 spec「列对齐云客」+ Plan 2 CustomerList 已验证模式,重构为分 tab(**用户已确认「UI 对齐先做」方向**)。
**⚠️ 数据 gap(重要,影响验证):** smoke.db `chat_group` 表 **0 行**(无群数据)。本 plan UI 对齐代码正确,但浏览器验证为**空表**(列结构对、无数据行)。wechat 群抓取在(`scrape_wechat_groups`),smoke.db 未填;WA 群抓取缺失。抓取 gap(跑 wechat 群抓取填 smoke.db + 实现 WA 群抓取)是**单独的后端/数据工作,不阻塞本 UI 对齐 plan**。
**运行命令(验证用):**
- 前端:`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 `views/GroupList.vue` — 重构:统一 8 列 → `el-tabs` 分 微信群 / WhatsApp群,各自列 + 操作列 `viewChat`(group-chat) + `owner_account_id` query 预筛 + drawer 保留
- **不改** `utils/groupFields.ts`(`readField`/`buildGroupSections` 复用;待核字段经 `readField` raw_json fallback 读取,失败显示 `--`,不必现在加 alias)
- **不改** `utils/customerFields.ts`(复用 Plan 2 的 `fromTypeLabel`)
**不改动:** 后端(`getGroups`/`chat_group` 在)、router(`group-chat` 路由在)、api、其他 .vue
---
## Task 1: GroupList.vue 重写(分 tab + 各自列 + 操作列 + query 预筛)
> 整文件重写(统一 8 列 → 分 tab 各自列)。drawer 保留现状结构(avatar + buildGroupSections + 查看聊天记录)。完全照搬 Plan 2 CustomerList 的 `tabState`/`loadTab`/`cellTimeText`/`viewChatFromDrawer`/channel watch/query watch 模式。
**Files:**
- Modify: `yunkes/src/views/GroupList.vue`(整文件替换为下方代码)
- [ ] **Step 1: 用以下内容整体替换 `GroupList.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 { getGroups } from '@/api'
import type { Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { buildGroupSections, readField } from '@/utils/groupFields'
import { fromTypeLabel } from '@/utils/customerFields'
/**
* 群列表 — channel-tabbed view mirroring 云客「沟通」板块的群列表.
*
* 微信群 tab 对齐 云客 /cms/wechat/wechatlist/wechatdetail/2/{wxId}
* (7 列: 群信息/数据来源/群主信息/群成员数量/群成员好友数量/消息数量/操作).
* WhatsApp群 tab 对齐 云客 /cms/wechat/waBusiness/friendlist 的群列表 tab
* (8 列: 序号/员工信息/员工WhatsApp/群信息/群消息数量/添加时间/最新消息时间/操作).
* 操作列「查看聊天记录」→ group-chat;row-click 开全字段 drawer。
*
* ⚠️ smoke.db 暂无群数据(chat_group 0 行)+ WA 群抓取缺失 → 表格空表(列结构对)。
* 部分 wechat 群字段(fromType/群主昵称/群主wxId/群成员好友数量)与全部 WA 群字段为
* 从云客采集推断,经 readField raw_json fallback 读取;待 smoke.db 有群数据核对。
*/
type TabKey = Channel
interface TabState {
items: Group[]
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()
// activeTab 跟随全局 channelStore(StaffList viewGroups 先 setChannel 再 push /groups)。
// channel 总是具体值('wechat' | 'whatsapp'),不会是 'all'。
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 drawerGroup = ref<Group | null>(null)
// query 过滤(两 tab 共享)
const ownerId = computed<string | null>(() =>
route.query.owner_account_id ? String(route.query.owner_account_id) : null,
)
// ---- cell helpers (structured-first via groupFields.readField) ----
function cell(row: Group, camelKey: string): unknown {
return readField(row, camelKey)
}
function cellText(row: Group, camelKey: string): string {
const v = cell(row, camelKey)
return v === null || v === undefined || v === '' ? '--' : String(v)
}
/** 时间列:number→formatTime;字符串直显;空→--。 */
function cellTimeText(row: Group, 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: Group, camelKey: string, fallback = 0): number {
const v = cell(row, camelKey)
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
/** 数据来源列:fromType 代码→文案(复用 customerFields.fromTypeLabel)。 */
function fromTypeText(row: Group): string {
return fromTypeLabel(cell(row, 'fromType'))
}
function imageCell(row: Group, 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 getGroups({
channel: ch,
page: st.page,
page_size: st.pageSize,
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)
}
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)
}
// ---- 操作列:查看聊天记录 → group-chat ----
function viewChat(row: Group): void {
if (!row.owner_account_id) {
ElMessage.warning('该群缺少归属账号,无法查看聊天记录')
return
}
router.push({
name: 'group-chat',
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id },
})
}
/** drawer 内「查看聊天记录」入口:drawerGroup 是 ref<Group|null>,模板嵌套不保证窄化,用 wrapper 守卫。 */
function viewChatFromDrawer(): void {
const row = drawerGroup.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: Group): void {
drawerGroup.value = row
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerGroup.value ? buildGroupSections(drawerGroup.value) : [],
)
const drawerAvatarSrc = computed<string | undefined>(() => {
const g = drawerGroup.value
if (!g) return undefined
// Group 类型无 head_image_url 结构化列(head_image_url 只在 Customer 上);
// imageCell 经 groupFields.readField 已读 STRUCTURED_ALIAS + raw_json.headImageUrl。
return imageCell(g, 'headImageUrl') ?? undefined
})
const drawerCanChat = computed(() => !!drawerGroup.value?.owner_account_id)
// 全局 channel 变化 → activeTab 跟随 + lazy-load(同 CustomerList)。
watch(
() => channelStore.channel,
(ch) => {
const next = ch === 'whatsapp' ? 'whatsapp' : 'wechat'
if (next !== activeTab.value) {
activeTab.value = next
if (!tabState[next].loaded) loadTab(next)
}
},
)
// query(owner)变化 → 重置两 tab page + 重载当前 tab。
watch(
() => ownerId.value,
() => {
tabState.wechat.loaded = false
tabState.whatsapp.loaded = false
tabState.wechat.page = 1
tabState.whatsapp.page = 1
loadTab(activeTab.value)
},
)
onMounted(() => {
loadTab(activeTab.value)
})
</script>
<template>
<div class="yk-groups">
<div class="yk-groups__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">群列表</h2>
<span class="yk-groups__sub">
共 {{ tabState[activeTab].total }} 条
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
</span>
</div>
<div class="yk-groups__filters">
<el-button v-if="ownerId" size="small" type="info" plain @click="clearOwnerFilter">
清除员工筛选
</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="yk-groups__tabs" @tab-change="onTabChange">
<!-- ============================================================
微信群 — 7 列,对齐云客 wechatdetail/2/{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="room_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无微信群数据"
@row-click="openDrawer"
>
<el-table-column label="群信息" min-width="180">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'chatRoomName')">{{ cellText(row, 'chatRoomName') }}</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="180">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'userName')">{{ cellText(row, 'userName') }}</div>
<div class="yk-staffinfo__muted">{{ cellText(row, 'wxId') }}</div>
</template>
</el-table-column>
<el-table-column label="群成员数量" width="110" align="right">
<template #default="{ row }">{{ cellNum(row, 'memberCount') }}</template>
</el-table-column>
<el-table-column label="群成员好友数量" width="130" align="right">
<template #default="{ row }">{{ cellNum(row, 'contactCount') }}</template>
</el-table-column>
<el-table-column label="消息数量" width="100" align="right">
<template #default="{ row }">{{ cellNum(row, 'messageCount') }}</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-groups__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群 — 8 列,对齐云客 waBusiness/friendlist 群列表 tab.
⚠️ WA 群抓取缺失 → 空表;字段推断,待 WA 抓取实现后核对。
============================================================ -->
<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="room_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="群信息" min-width="180">
<template #default="{ row }">
<div class="yk-staffinfo__nick" :title="cellText(row, 'chatRoomName')">{{ cellText(row, 'chatRoomName') }}</div>
</template>
</el-table-column>
<el-table-column label="群消息数量" width="120" align="right">
<template #default="{ row }">{{ cellNum(row, 'messageCount') }}</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-groups__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-groups__hint">点击任意一行查看群全部字段。</p>
<!-- 全字段抽屉(保留现状:avatar + buildGroupSections + 查看聊天记录) -->
<el-drawer
v-model="drawerOpen"
:title="drawerGroup ? `群详情 · ${drawerGroup.room_name ?? drawerGroup.room_id}` : '群详情'"
direction="rtl"
size="560px"
>
<template v-if="drawerGroup">
<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">
{{ (drawerGroup.room_name || drawerGroup.room_id || '?').slice(0, 1) }}
</el-avatar>
<div class="yk-drawer__avatar-meta">
<div class="yk-drawer__avatar-name">
{{ drawerGroup.room_name || drawerGroup.room_id }}
</div>
<div class="yk-drawer__avatar-sub">
{{ channelLabel(drawerGroup.channel) }}
<template v-if="cell(drawerGroup, 'wechatNickName')"> · {{ cellText(drawerGroup, 'wechatNickName') }}</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-groups__header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 12px; }
.yk-groups__sub { color: #909399; font-size: 13px; }
.yk-groups__filters { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.yk-groups__tabs { margin-top: 4px; }
.yk-groups__pager { margin-top: 12px; display: flex; justify-content: flex-end; }
.yk-groups__hint { margin: 8px 2px 0; font-size: 12px; color: #c0c4cc; }
.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` 未导出 → 确认 Plan 2 Task 1 已加(`@/utils/customerFields`);`Group` 上无某字段 → 核对 `api/types.ts`;`group-chat` 路由名/params → 核对 `router/index.ts`(应 `{channel, ownerId, roomId}`)。drawer 已用 `viewChatFromDrawer` wrapper,**不要**用 `!`。
- [ ] **Step 3: Commit**
```bash
git add yunkes/src/views/GroupList.vue
git commit -m "$(cat <<'EOF'
feat(groups): align GroupList to 云客 — per-channel tabs, view-chat action
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: 端到端验证 + 收尾
> smoke.db 0 群 → 浏览器验证为**空表**(列结构对、无数据行)。重点验:两 tab 列头、tab 切换、操作按钮、空表提示、vue-tsc、pytest。
- [ ] **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
cd yunkes && npm run dev
```
- [ ] **Step 2: 浏览器验证 `/groups`**
- 微信群 tab:7 列表头(群信息/数据来源/群主信息/群成员数量/群成员好友数量/消息数量/操作)与云客一致;空表提示「暂无微信群数据」。
- WhatsApp群 tab:8 列表头(序号/员工信息/员工WhatsApp/群信息/群消息数量/添加时间/最新消息时间/操作);空表提示。
- 切 tab:不报错(各自空 loadTab)。
- StaffList → 点员工「群聊」(viewGroups) → `/groups?owner_account_id=X` + 落对应 tab + 「清除员工筛选」chip。
- (无数据 → viewChat/drawer 点行无法验,待有群数据后补验)
- [ ] **Step 3: 后端回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
```
预期:全 PASS(≥330;本 plan 无后端改动)。
- [ ] **Step 4: vue-tsc 复核**
```bash
cd yunkes && npx vue-tsc -b
```
预期:exit 0。
- [ ] **Step 5: 截图留档**(两 tab 空表 + 列头),更新本 plan 附录。
- [ ] **Step 6: Commit(仅当验证阶段有代码微调时;纯验证无改动则跳过)**
```bash
git add yunkes/src/views/GroupList.vue
git commit -m "test(groups): e2e verify GroupList alignment vs 云客 (empty: smoke.db 0 groups)"
```
---
## 收尾验收清单
- [ ] `cd yunkes && npx vue-tsc -b` exit 0,无 console 报错。
- [ ] `/groups` 两 tab 列名/顺序/操作按钮与云客一致(截图留档)。
- [ ] StaffList「群聊」→ GroupList 预筛 + chip + 清除,联动正确。
- [ ] 后端 pytest 仍 ≥330 绿(无后端改动)。
- [ ] **已知限制(数据 gap,非本 plan 范围)**:smoke.db `chat_group` 0 行 → 表格空表(列结构对);待 wechat 群抓取填 smoke.db + WA 群抓取实现后,字段映射按附录核对 + 补数据验证。
---
## 字段映射附录 + 待核 + gap
### 微信群(云客 wechatdetail/2)→ yunkes Group 字段
| 云客列 | raw_json key(readField) | 确定性 |
|---|---|---|
| 群信息(群名) | `chatRoomName`(结构化 `room_name`) | ✅ |
| 数据来源 | `fromType` | ⚠️ 待核(group raw_json 是否有;复用 fromTypeLabel) |
| 群主信息(昵称) | `userName` | ⚠️ 待核("开\*果" 是 userName 还是 ownerNickName) |
| 群主信息(wxId) | `wxId` | ⚠️ 待核(群主 wxId 字段名;可能是 ownerWxId) |
| 群成员数量 | `memberCount`(结构化 `member_count`) | ✅ |
| 群成员好友数量 | `contactCount` | ⚠️ 待核(字段名猜测;云客显示好友数) |
| 消息数量 | `messageCount`(结构化 `message_count`) | ✅ |
| 操作 | — | viewChat → group-chat ✅ |
### WhatsApp 群(云客 waBusiness/friendlist 群列表)→ yunkes Group 字段
| 云客列 | raw_json key(推断) | 确定性 |
|---|---|---|
| 序号 | 行号(waSerial) | ✅ |
| 员工信息 | `salesNickName` + `salePhone` | ⚠️ 全推断(WA 群无抓取/raw_json) |
| 员工WhatsApp | `userName` + `uid` | ⚠️ |
| 群信息 | `chatRoomName` | ⚠️ |
| 群消息数量 | `messageCount` | ⚠️ |
| 添加时间 | `createTime` | ⚠️ |
| 最新消息时间 | `lastUpdate` | ⚠️ |
| 操作 | — | viewChat ✅ |
**核对方法(待数据就绪):** smoke.db 有群数据后,`select raw_json from chat_group where channel='wechat' limit 1` dump keys → 核对上表 ⚠️ 字段名,修正 readField key。WA 群待抓取实现后同理。
---
## 后续(不在本 plan 范围)
- **群抓取 gap(数据工作,单独 plan):**
- wechat:跑 `scrape_wechat_groups` 填 smoke.db(抓取代码在,需跑同步)。
- whatsapp:实现 WA 群抓取(`whatsapp.py` 加 group 实体抓取 + `_normalize`;spec WhatsApp 现未列群,需先探云客 WA 群接口)。
- Plan 4: ChatTimeline 会话列表 + `/api/sessions` 聚合端点。
- Plan 5: SearchView + AccountList 删除。