Files
yunke/docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md
T
浅念 9499110427 docs(plan): record wechat staff-list probe + adjust Task 8/9
WeChat interactions are navigation (not modal): 好友数/群数→customer/group list, 昵称→chat. Task 8 redone as navigation; Task 9 uses owner_account_id + row.channel.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 18:35:18 +08:00

741 lines
32 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.
# StaffList 交互补全 + customer 后端基础 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:** 把 StaffList(员工列表)的交互对齐云客——统计数字点击弹明细 modal、操作按钮跳转按员工筛选的列表、昵称跳聊天——并补齐所需后端能力(customer `add_time` 列 + 筛选参数),抽通用明细 modal 组件供后续页面复用。
**Architecture:** 后端先 TDD 扩展(Customer.add_time 列 + Alembic 迁移 + scraper 映射 + 回填脚本 + router 三个筛选参数),再前端:通用 `YkStaffDetailModal` 组件 + StaffList 三类交互接入 + CustomerList/GroupList 读 query 预筛。
**Tech Stack:** FastAPI + SQLAlchemy 2.0 async + Alembic(后端);Vue3 + TS + Element Plus + Pinia(前端)。
**Spec:** `docs/superpowers/specs/2026-08-05-yunke-ui-alignment-design.md`
**已采集事实(WhatsApp 员工表交互,Playwright 验证):**
- 添加人数/沟通人数(数字)→ modal(明细表)
- 操作「好友列表」→ `/waBusiness/friendlist/0/{userId}`;「群聊」→ `friendlist/1/{userId}`
- WA 昵称 → 新 tab 聊天记录(`wschatroom`,会话列表+消息流,只读)
**运行命令(验证用):**
- 后端:`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`
- 后端测试:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q`
- 前端类型:`cd yunkes && npx vue-tsc -b`
---
## File Map
**后端(yunke-server/):**
- Modify `app/db/models.py` — `Customer` 加 `add_time` 列
- Modify `app/api/schemas.py` — `CustomerOut` 加 `add_time`
- Create `migrations/versions/<rev>_customer_add_time.py` — Alembic 迁移
- Modify `app/scraper/wechat.py` — customer 映射加 `add_time`
- Modify `app/scraper/whatsapp.py` — customer 映射加 `add_time`
- Create `scripts/backfill_customer_add_time.py` — 解析 raw_json 回填历史
- Modify `app/api/routers/customer.py` — 加 `user_id`/`add_time_since`/`last_chat_since`
- Modify `tests/test_api_customer.py` — 新筛选参数测试
- Modify `tests/test_scrape_wechat_friends.py` / `test_scrape_whatsapp_friends.py` — add_time 映射测试
**前端(yunkes/src/):**
- Modify `api/types.ts` — `Customer` 加 `add_time`
- Modify `api/index.ts` — `CustomerQuery` + `getCustomers` 加三参数
- Create `components/YkStaffDetailModal.vue` — 通用明细 modal
- Modify `views/StaffList.vue` — 数字→modal、操作→跳转、昵称→聊天
- Modify `views/CustomerList.vue` / `views/GroupList.vue` — 读 `route.query` owner/user 预筛
---
## Task 0: 探测微信员工表交互(产出目标映射)
> WhatsApp 员工表交互已采集(见上)。微信员工表(`/cms/wechat/wechatlist`)列结构已采,但**数字是否可点击未探测**。本 task 用同一探测脚本补齐。
**Files:** 无代码改动,产出记录写入本 plan 末尾「探测结果」。
- [ ] **Step 1: 浏览器登录云客 + 打开微信员工列表**(Playwright;若已登录复用会话)
导航到 `https://crm2.yunkecn.com/cms/wechat/wechatlist`。
- [ ] **Step 2: 跑探测脚本**(参照 spec「交互探测方法」):对第一行每个可点元素(button/a)点击,记录 URL 变化 / modal title / popup,每次点前回列表页、关残留 modal/tab。
- [ ] **Step 3: 把结果填到本 plan 末尾「微信员工表探测结果」**,据此调整 Task 7/8 的数字→维度映射(微信表的"好友数/消息数/沟通数/及时回复"哪些可点、点出什么 modal)。
- [ ] **Step 4: Commit** 探测结论(更新本 plan)。
```bash
git add docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md
git commit -m "docs: record wechat staff-list interaction probe results"
```
---
## Task 1: Customer.add_time 列 + schema + Alembic 迁移
**Files:**
- Modify: `yunke-server/app/db/models.py:235`(`Customer`,`last_chat_time` 下方加 `add_time`)
- Modify: `yunke-server/app/api/schemas.py`(`CustomerOut`)
- Create: `yunke-server/migrations/versions/<rev>_customer_add_time.py`
- [ ] **Step 1: 加模型列**(`app/db/models.py`,`Customer` 类内,紧跟 `last_chat_time`):
```python
last_chat_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
add_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # ms epoch; from raw_json.addTime
```
- [ ] **Step 2: 加 schema 字段**(`app/api/schemas.py`,`CustomerOut`,紧跟 `last_chat_time`):
```python
last_chat_time: int | None = None
add_time: int | None = None
```
- [ ] **Step 3: 生成 Alembic 迁移**
```bash
cd yunke-server && DB_URL="sqlite+aiosqlite:///./_autogen.db" .venv/Scripts/python.exe -m alembic revision --autogenerate -m "customer add_time"
```
预期:`migrations/versions/<hash>_customer_add_time.py` 生成,内含 `op.add_column('customer', sa.Column('add_time', sa.BigInteger(), nullable=True))`。
- [ ] **Step 4: 应用迁移到 smoke.db 验证**
```bash
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m alembic upgrade head
```
预期:`Running upgrade ... -> <hash>, customer add_time`。验证:`sqlite3 smoke.db "PRAGMA table_info(customer)"` 含 `add_time`。
- [ ] **Step 5: 跑模型测试确认不破坏**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_models.py tests/test_api_customer.py -x -q
```
预期:全 PASS。
- [ ] **Step 6: Commit**
```bash
git add yunke-server/app/db/models.py yunke-server/app/api/schemas.py yunke-server/migrations/versions/
git commit -m "feat(db): add Customer.add_time column + migration"
```
---
## Task 2: scraper 映射 add_time(wechat + whatsapp)
> wechat `addTime` 实测为字符串 `"YYYY-MM-DD HH:mm:ss"`(见 smoke.db);whatsapp `lastUpdate` 已用 `parse_datetime_to_ms`。`add_time` 要支持字符串与 epoch 两种输入。复用 `parse_datetime_to_ms`(`app/scraper/whatsapp.py` 已定义;若 wechat.py 未 import,提到 `app/scraper/_datetime.py` 共享或直接 import)。
**Files:**
- Modify: `yunke-server/app/scraper/wechat.py`(customer 映射函数,~line 164-192)
- Modify: `yunke-server/app/scraper/whatsapp.py`(customer 映射函数,~line 252-264)
- Test: `yunke-server/tests/test_scrape_wechat_friends.py`, `test_scrape_whatsapp_friends.py`
- [ ] **Step 1: 写失败测试**(`tests/test_scrape_wechat_friends.py`,加一个 case)。
函数名是 `_normalize`(`app/scraper/wechat.py:157`,**不是** `_normalize_friend`):
```python
from app.scraper.wechat import _normalize
def test_normalize_maps_add_time_string_to_epoch():
record = {"friendWxId": "x", "addTime": "2026-04-28 16:24:39", "lastChatTime": 1700000000000}
out = _normalize(record)
# parse_datetime_to_ms 按 naive UTC 解析;断言是合理的近期 epoch,不写死值(避免 tz/算术陷阱)。
# 2026 年任意时刻 > 1.76e12。
assert isinstance(out["add_time"], int) and out["add_time"] > 1_760_000_000_000
```
- [ ] **Step 2: 跑测试确认 FAIL**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wechat_friends.py::test_normalize_friend_maps_add_time_string_to_epoch -x -q
```
预期:KeyError 或 None(add_time 未映射)。
- [ ] **Step 3: wechat 映射加 add_time**(`app/scraper/wechat.py`,customer 映射 return dict 内,紧跟 `last_chat_time`):
```python
"last_chat_time": record.get("lastChatTime"),
"add_time": _parse_add_time(record.get("addTime")),
```
在文件 helper 区加(处理字符串+epoch)。`parse_datetime_to_ms` 实际定义在 `app/scraper/parsers.py`(`whatsapp.py` 仅 re-import),按 **naive UTC** 解析:
```python
from app.scraper.parsers import parse_datetime_to_ms
_ADD_TIME_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M")
def _parse_add_time(v):
"""addTime: ms-epoch int 或 'YYYY-MM-DD HH:mm[:ss]' 字符串 → ms epoch (naive UTC)。"""
if v is None or v == "":
return None
if isinstance(v, (int, float)):
return int(v)
return parse_datetime_to_ms(v, _ADD_TIME_FORMATS)
```
> 先读 `app/scraper/parsers.py` 的 `parse_datetime_to_ms` 确认它接受 format tuple 且返回 ms epoch(naive UTC)。若签名不同,改为内联:`int(dt.datetime.strptime(v, fmt).replace(tzinfo=dt.UTC).timestamp() * 1000)`(遍历 `_ADD_TIME_FORMATS` 容错)。
- [ ] **Step 4: 跑 wechat 测试确认 PASS**。然后处理 whatsapp(`app/scraper/whatsapp.py` 的 `_normalize_whatsapp_friend`,~line 252)。
**先 dump 确认 WA customer 有无 customer-side add-time 字段**:
```bash
cd yunke-server && .venv/Scripts/python.exe -c "import sqlite3,json; c=sqlite3.connect('smoke.db'); r=c.execute(\"SELECT raw_json FROM customer WHERE channel='whatsapp' LIMIT 1\").fetchone(); print(list(json.loads(r[0]).keys()) if r else 'none')"
```
⚠️ 关键:whatsapp `raw_json` 里的 `createTime` 是 **owner-staff** 字段(见 `_normalize_whatsapp_friend` docstring line 246-251),**不是** customer 的添加时间。若 dump 确认 WA customer 无独立的 add-time 字段,则 **WA 的 add_time 留 None**,不要强行用 createTime(会语义错误)。仅 wechat add_time 可靠填充;WA"添加人数"modal 届时按 wechat 路径或降级处理。
- [ ] **Step 5: 跑全部 scraper 测试**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wechat_friends.py tests/test_scrape_whatsapp_friends.py -x -q
```
预期:PASS。
- [ ] **Step 6: Commit**
```bash
git add yunke-server/app/scraper/ yunke-server/tests/test_scrape_wechat_friends.py yunke-server/tests/test_scrape_whatsapp_friends.py
git commit -m "feat(scraper): map customer add_time from raw_json"
```
---
## Task 3: add_time 回填脚本(历史数据)
> 解析现有 customer 行的 `raw_json.addTime` → `add_time` 列。不重抓(区别于 `backfill_customers.py`)。参照其 async sessionmaker + batch commit 模式。
**Files:**
- Create: `yunke-server/scripts/backfill_customer_add_time.py`
- [ ] **Step 1: 写脚本**(参照 `scripts/backfill_customers.py` 的 engine/sessionmaker 设置):
```python
"""Backfill Customer.add_time from raw_json.addTime (no re-scrape).
Iterates existing customer rows, parses raw_json.addTime (str or epoch) →
add_time ms-epoch, updates in batches. Idempotent: skips rows already set.
"""
import asyncio
import json
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import models # noqa: F401
from app.db.models import Customer
from app.scraper.wechat import _parse_add_time # reuse the parser
BATCH = 200
async def main(db_url: str = "sqlite+aiosqlite:///./smoke.db") -> None:
engine = create_async_engine(db_url)
sm = async_sessionmaker(engine, expire_on_commit=False)
done = 0
async with sm() as s:
rows = (await s.execute(select(Customer.friend_id, Customer.raw_json, Customer.add_time))).all()
for i, (fid, raw, existing) in enumerate(rows):
if existing or not raw:
continue
val = _parse_add_time(raw.get("addTime"))
if val is None:
continue
await s.execute(update(Customer).where(Customer.friend_id == fid).values(add_time=val))
done += 1
if done % BATCH == 0:
await s.commit()
print(f"[{time.strftime('%H:%M:%S')}] {done} updated...")
await s.commit()
print(f"DONE — {done} customers got add_time")
await engine.dispose()
if __name__ == "__main__":
import time
asyncio.run(main())
```
> 补 `import time` 到顶部(plan 内为可读性放底部,实际放顶部)。
- [ ] **Step 2: 跑回填(smoke.db)**
```bash
cd yunke-server && .venv/Scripts/python.exe -m scripts.backfill_customer_add_time
```
预期:`DONE — N customers got add_time`。
- [ ] **Step 3: 验证** —— query smoke.db 确认 add_time 非空:
```bash
cd yunke-server && .venv/Scripts/python.exe -c "import sqlite3;c=sqlite3.connect('smoke.db');print('with add_time:', c.execute('SELECT COUNT(*) FROM customer WHERE add_time IS NOT NULL').fetchone()[0]); print('total:', c.execute('SELECT COUNT(*) FROM customer').fetchone()[0])"
```
- [ ] **Step 4: Commit**
```bash
git add yunke-server/scripts/backfill_customer_add_time.py
git commit -m "feat(scripts): backfill Customer.add_time from raw_json"
```
---
## Task 4: customer router 加 user_id / add_time_since / last_chat_since(TDD)
**Files:**
- Modify: `yunke-server/app/api/routers/customer.py`(`list_customers` 签名 + conditions)
- Test: `yunke-server/tests/test_api_customer.py`
- [ ] **Step 1: 写失败测试**(`tests/test_api_customer.py`)。
⚠️ **不要扩展共享 `client` fixture**——它内联 seed 3 行,且 4 个现有测试断言 `total == 3`(`test_list_default_page_returns_all` 等),加行会破坏它们。**新建独立 fixture**(参照 `tests/test_api_customer.py:47-93` 的导入与 app/client 构造方式,对齐 `conftest.py` 的 `async_engine`):
```python
@pytest_asyncio.fixture
async def filter_client(async_engine):
async with async_engine.connect() as conn:
await conn.run_sync(Base.metadata.create_all)
Session = async_sessionmaker(async_engine, expire_on_commit=False)
async with Session() as s:
s.add_all([
Customer(friend_id="u1_a", channel="wechat", user_id="U1", add_time=1000, last_chat_time=500),
Customer(friend_id="u1_b", channel="wechat", user_id="U1", add_time=2000, last_chat_time=2000),
Customer(friend_id="u2_a", channel="wechat", user_id="U2", add_time=3000, last_chat_time=3000),
])
await s.commit()
async with AsyncClient(app=app, base_url="http://test") as c:
yield c
async def test_filter_by_user_id(filter_client):
r = await filter_client.get("/api/customers", params={"user_id": "U1"})
assert {c["friend_id"] for c in r.json()["items"]} == {"u1_a", "u1_b"}
async def test_filter_by_add_time_since(filter_client):
r = await filter_client.get("/api/customers", params={"add_time_since": 1500})
ids = [c["friend_id"] for c in r.json()["items"]]
assert "u1_a" not in ids and "u1_b" in ids and "u2_a" in ids
async def test_filter_by_last_chat_since(filter_client):
r = await filter_client.get("/api/customers", params={"last_chat_since": 1500})
assert "u1_a" not in [c["friend_id"] for c in r.json()["items"]]
```
- [ ] **Step 2: 跑测试确认 FAIL**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_customer.py -k "user_id or add_time_since or last_chat_since" -x -q
```
预期:FAIL(参数未识别 / 无筛选)。
- [ ] **Step 3: 实现筛选**(`app/api/routers/customer.py`):
签名加:
```python
user_id: Annotated[str | None, Query()] = None,
add_time_since: Annotated[int | None, Query(ge=0)] = None,
last_chat_since: Annotated[int | None, Query(ge=0)] = None,
```
conditions 加:
```python
if user_id is not None:
conditions.append(Customer.user_id == user_id)
if add_time_since is not None:
conditions.append(Customer.add_time >= add_time_since)
if last_chat_since is not None:
conditions.append(Customer.last_chat_time >= last_chat_since)
```
- [ ] **Step 4: 跑测试确认 PASS**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_customer.py -x -q
```
- [ ] **Step 5: 全量回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q
```
预期:全 PASS(≥323)。
- [ ] **Step 6: Commit**
```bash
git add yunke-server/app/api/routers/customer.py yunke-server/tests/test_api_customer.py
git commit -m "feat(api): customer filters user_id / add_time_since / last_chat_since"
```
---
## Task 5: 前端 api/types 加 customer 参数与字段
**Files:**
- Modify: `yunkes/src/api/types.ts`(`Customer`)
- Modify: `yunkes/src/api/index.ts`(`CustomerQuery` + `getCustomers`)
- [ ] **Step 1: `types.ts` `Customer` 加字段**(紧跟 `last_chat_time`):
```ts
last_chat_time?: number | null
/** ms epoch of friend add time (from raw_json.addTime). */
add_time?: number | null
```
- [ ] **Step 2: `api/index.ts` `CustomerQuery` 加三参数 + `getCustomers` 透传**:
```ts
export interface CustomerQuery {
channel?: Channel | 'all'
page?: number
page_size?: number
owner_account_id?: string
user_id?: string
department_id?: string
add_time_since?: number
last_chat_since?: number
}
export function getCustomers(query: CustomerQuery = {}): Promise<Page<Customer>> {
return getJson<Page<Customer>>(
'/customers',
clean({
page: query.page ?? 1,
page_size: query.page_size ?? 20,
channel: query.channel === 'all' ? undefined : query.channel,
owner_account_id: query.owner_account_id,
user_id: query.user_id,
department_id: query.department_id,
add_time_since: query.add_time_since,
last_chat_since: query.last_chat_since,
}),
)
}
```
- [ ] **Step 3: 类型检查**
```bash
cd yunkes && npx vue-tsc -b
```
预期:exit 0。
- [ ] **Step 4: Commit**
```bash
git add yunkes/src/api/types.ts yunkes/src/api/index.ts
git commit -m "feat(api): customer query params user_id/add_time_since/last_chat_since"
```
---
## Task 6: YkStaffDetailModal 通用组件
> 云客"统计数字→明细 modal"的通用复用件。Props:标题、API 函数、查询参数、列定义。内部 `el-dialog` + `el-table` + `el-pagination`。
**Files:**
- Create: `yunkes/src/components/YkStaffDetailModal.vue`
- [ ] **Step 1: 写组件**:
```vue
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { Page } from '@/api/types'
/**
* 通用明细 modal —— 复刻云客"统计数字点击弹明细表"。
* 调用方传 fetcher(返回 Page<T>)+ 列定义;组件负责 dialog/table/pagination。
*/
interface Col {
prop: string
label: string
width?: number | string
minWidth?: number | string
align?: 'left' | 'center' | 'right'
}
const props = defineProps<{
modelValue: boolean
title: string
cols: Col[]
/** fetcher(page, page_size) → Page<T>。宽松 Page<any>:Customer 无 index signature,
* 不可直接赋 Page<Record<string,unknown>>,故用 any 接纳 Page<Customer> 等。 */
fetcher: (page: number, page_size: number) => Promise<Page<any>>
pageSizes?: number[]
}>()
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
const loading = ref(false)
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(props.pageSizes?.[0] ?? 10)
async function load() {
loading.value = true
try {
const res = await props.fetcher(page.value, pageSize.value)
items.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
page.value = 1
load()
}
},
)
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="900px"
@update:model-value="emit('update:modelValue', $event)"
>
<el-table v-loading="loading" :data="items" border style="width: 100%" size="small">
<el-table-column
v-for="c in cols"
:key="c.prop"
:prop="c.prop"
:label="c.label"
:width="c.width"
:min-width="c.minWidth"
:align="c.align"
show-overflow-tooltip
/>
</el-table>
<div style="margin-top: 10px; display: flex; justify-content: flex-end">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="pageSizes ?? [10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
background
size="small"
@current-change="(p: number) => { page = p; load() }"
@size-change="(s: number) => { pageSize = s; page = 1; load() }"
/>
</div>
</el-dialog>
</template>
```
- [ ] **Step 2: 类型检查**
```bash
cd yunkes && npx vue-tsc -b
```
预期:exit 0。
- [ ] **Step 3: Commit**
```bash
git add yunkes/src/components/YkStaffDetailModal.vue
git commit -m "feat(ui): YkStaffDetailModal shared detail-modal component"
```
---
## Task 7: StaffList WhatsApp 数字→modal(添加人数 / 沟通人数)
> 云客 WhatsApp 员工表:点「添加人数」→ modal 列 `序号/员工信息/员工WhatsApp/客户WhatsApp/添加时间/最后沟通时间`;点「沟通人数」→ 类似(沟通客户)。我们用 customer 端点 + `user_id` + `add_time_since`/`last_chat_since` 筛选近似。
**Files:**
- Modify: `yunkes/src/views/StaffList.vue`
- [ ] **Step 1: import 组件 + getCustomers**
StaffList `<script setup>` 顶部加:
```ts
import { getCustomers } from '@/api'
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
```
加 modal 状态 + fetcher(script 内):
```ts
// ---- detail modal (stat-number click → 明细) ----
const detailOpen = ref(false)
const detailTitle = ref('')
const detailFetcher = ref<(p: number, s: number) => Promise<any>>(async () => ({ items: [], total: 0 }))
const detailCols = ref<any[]>([])
const baseCustomerCols = [
{ prop: 'friend_nickname', label: '客户', minWidth: 140 },
{ prop: 'phone', label: '客户号码', width: 130 },
{ prop: 'add_time', label: '添加时间', width: 150 },
{ prop: 'last_chat_time', label: '最后沟通', width: 150 },
]
function openAddedModal(row: Account) {
// 添加人数 = 该员工最近新增好友(add_time 近 N 天;N 待 Task 0 确认,默认 7)
const since = Date.now() - 7 * 86400000
detailTitle.value = `添加人数 · ${row.user_name ?? row.account_id}`
detailCols.value = baseCustomerCols
detailFetcher.value = (p, s) =>
getCustomers({ user_id: row.user_id!, channel: 'whatsapp', add_time_since: since, page: p, page_size: s })
detailOpen.value = true
}
function openTalkedModal(row: Account) {
const since = Date.now() - 7 * 86400000
detailTitle.value = `沟通人数 · ${row.user_name ?? row.account_id}`
detailCols.value = baseCustomerCols
detailFetcher.value = (p, s) =>
getCustomers({ user_id: row.user_id!, channel: 'whatsapp', last_chat_since: since, page: p, page_size: s })
detailOpen.value = true
}
```
> `Date.now()` 在 StaffList 浏览器运行时可用(非 workflow 脚本)。
- [ ] **Step 2: 模板——把 WhatsApp 的「添加人数」「沟通人数」列从纯数字改为可点 link**
找到 WhatsApp tab 的:
```html
<el-table-column label="添加人数" width="100" align="right">
<template #default="{ row }">{{ cellNum(row, 'friendAddNum') }}</template>
</el-table-column>
```
改为:
```html
<el-table-column label="添加人数" width="100" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="openAddedModal(row)">
{{ cellNum(row, 'friendAddNum') }}
</el-button>
</template>
</el-table-column>
```
「沟通人数」同理用 `openTalkedModal`。
- [ ] **Step 3: 模板末尾挂 modal**(在 `</el-tabs>` 后、`yk-staff__hint` 前):
```html
<YkStaffDetailModal v-model="detailOpen" :title="detailTitle" :cols="detailCols" :fetcher="detailFetcher" />
```
- [ ] **Step 4: 类型检查 + 浏览器验证**
```bash
cd yunkes && npx vue-tsc -b
```
浏览器打开 `http://localhost:5173/staff` → WhatsApp tab → 点「添加人数」数字 → 确认弹 modal 且有数据。
- [ ] **Step 5: Commit**
```bash
git add yunkes/src/views/StaffList.vue
git commit -m "feat(staff): WhatsApp stat-number click → detail modal"
```
---
## Task 8: StaffList 微信交互(跳转类,无 modal)
> ⚠️ **在 Task 9、10 之后执行**(复用 `viewCustomers`/`viewGroups`/`viewChat`)。
> Task 0 探测结论:微信员工表数字点击是**跳转**(非 modal),与 WhatsApp 不同。微信 tab 全部交互复用 Task 9/10 的跳转函数。
**Files:** Modify `yunkes/src/views/StaffList.vue`
- [ ] **Step 1: 微信 tab「好友数」列**——把复合单元格里「好友数」的值包成可点 link → `viewCustomers(row)`(`row.channel` 自动是 wechat,owner=wxId=`row.account_id`);「微信群数」值 → `viewGroups(row)`。
例(在微信 tab「好友数」`el-table-column` 的 `yk-pair` 里):
```html
<el-button link type="primary" size="small" @click.stop="viewCustomers(row)">{{ cellNum(row, 'contactsNum') }}</el-button>
```
- [ ] **Step 2: 微信 tab 昵称**(员工信息列 `cellText(row, 'nickName')`)→ `viewChat(row)`(复用 Task 10)。
- [ ] **Step 3: 手机号**——云客跳员工详情,我们暂无此页 → **降级**:不接入(标注:降级,后续员工详情页再做)。
- [ ] **Step 4: 浏览器验证微信 tab**:点好友数→客户列表(wechat, owner=wxId)、群数→群列表、昵称→聊天。
- [ ] **Step 5: 类型检查 + Commit**
```bash
cd yunkes && npx vue-tsc -b
git add yunkes/src/views/StaffList.vue
git commit -m "feat(staff): wechat stat-number/nickname → navigation"
```
---
## Task 9: 操作按钮跳转 + CustomerList/GroupList 读 query(含后端 groups `user_id` TDD)
> ⚠️ 本 task 含一个**后端 TDD 子任务**(Step 1:groups router 加 `user_id`,独立于前端改动,等同 Task 4 的规模,不要跳过测试)。
> 云客「好友列表」→ 该员工客户列表;「群聊」→ 该员工群列表。我们 `router.push('/customers') + setChannel + ?user_id=`;目标页读 query 预筛。
**Files:**
- Modify: `yunkes/src/views/StaffList.vue`(已有 `viewCustomers`/`viewGroups`,改为带 query + 按 row)
- Modify: `yunkes/src/views/CustomerList.vue`(读 `route.query.user_id`)
- Modify: `yunkes/src/views/GroupList.vue`(读 `route.query.user_id`,调 `/api/groups` 待加 `user_id`——见下注)
> **注**:groups router 加 `user_id` 不在本 plan(spec 列了但属通用基础,可在此顺带加,参照 Task 4 模式:`ChatGroup.owner_user_id == user_id`)。本 task 顺带加。
- [ ] **Step 1: groups router 加 user_id**(`app/api/routers/groups.py`,参照 customer.py):
签名加 `user_id: Annotated[str | None, Query()] = None`,conditions 加 `if user_id is not None: conditions.append(ChatGroup.owner_user_id == user_id)`。加测试 `tests/test_api_groups.py::test_filter_by_user_id`。跑测试 PASS。
- [ ] **Step 2: 前端 GroupQuery + getGroups 加 user_id**(`api/index.ts`)。
- [ ] **Step 3: StaffList `viewCustomers`/`viewGroups` 改为按 row + 带 query**。用 `owner_account_id`(= `row.account_id`:微信 wxId / WhatsApp uid 都成立),CustomerList/GroupList **已支持** `owner_account_id` 筛选,无需等 user_id:
```ts
function viewCustomers(row: Account) {
channelStore.setChannel(row.channel)
router.push({ path: '/customers', query: { owner_account_id: row.account_id } })
}
function viewGroups(row: Account) {
channelStore.setChannel(row.channel)
router.push({ path: '/groups', query: { owner_account_id: row.account_id } })
}
```
模板(两个 tab 的操作列)改为 `@click.stop="viewCustomers(row)"` / `@click.stop="viewGroups(row)"`。
- [ ] **Step 4: CustomerList 读 `owner_account_id` query**(参照它已读 `department_id` 的模式,~line 33):
```ts
const ownerId = route.query.owner_account_id ? String(route.query.owner_account_id) : null
```
`getCustomers` 调用加 `owner_account_id: ownerId`;watch 响应 `ownerId` 变化;顶部加 chip「筛选自员工」+ 清除(清除时 `router.replace({ query: {} })`)。
- [ ] **Step 5: GroupList 同理读 `owner_account_id` query**。
- [ ] **Step 6: 浏览器验证** —— StaffList WhatsApp → 点「好友列表」→ 跳客户列表,只显示该员工客户 + 顶部 chip;点「群聊」同理。
- [ ] **Step 7: 类型检查 + 全后端回归 + Commit**
```bash
cd yunkes && npx vue-tsc -b
cd ../yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q
git add ...
git commit -m "feat(staff): action buttons → filtered customer/group list"
```
---
## Task 10: 昵称→聊天(ChatTimeline)
> 云客点 WA 昵称开聊天记录页(会话列表+消息流)。我们 ChatTimeline 当前是消息流,需具体 talkerId。点员工昵称跳"该账号聊天"——暂降级为跳 ChatTimeline 用该账号最近一条会话(spec 风险章节已记)。
**Files:** Modify `yunkes/src/views/StaffList.vue`
- [ ] **Step 1: 加 `viewChat` 函数**(取该 owner 最近一条 message 的 talker_id,跳 ChatTimeline):
```ts
import { getMessages } from '@/api'
async function viewChat(row: Account) {
// 最近一条会话的 talker
const res = await getMessages({ channel: row.channel, owner_account_id: row.account_id, page: 1, page_size: 1 })
const m = res.items[0]
if (!m) { ElMessage.info('该账号暂无聊天记录'); return }
const path = m.room_id
? `/group/${row.channel}/${row.account_id}/${m.room_id}`
: `/chat/${row.channel}/${row.account_id}/${m.talker_id}`
router.push(path)
}
```
- [ ] **Step 2: 模板——WhatsApp「员工WhatsApp」列昵称可点**:
找到 `<span class="yk-staffinfo__nick" :title=...">{{ cellText(row, 'nickName') }}</span>`,包一层 `@click.stop="viewChat(row)"` + `class="yk-link"`,加 `.yk-link { color: var(--el-color-primary); cursor: pointer; }` 样式。
- [ ] **Step 3: 浏览器验证** —— 点 WhatsApp 昵称 → 跳 ChatTimeline 该账号聊天。
- [ ] **Step 4: 类型检查 + Commit**
```bash
cd yunkes && npx vue-tsc -b
git add yunkes/src/views/StaffList.vue
git commit -m "feat(staff): WhatsApp nickname → chat timeline"
```
---
## 收尾验证
- [ ] 全后端测试绿:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q`(≥323)
- [ ] 前端类型:`cd yunkes && npx vue-tsc -b` exit 0
- [ ] 浏览器 `http://localhost:5173/staff`:微信+WhatsApp 两 tab,数字点击、操作跳转、昵称跳聊天全部对照云客验证,截图留档。
- [ ] `/api/customers?user_id=X&add_time_since=Y` 返回正确筛选项(curl 验证)。
---
## 微信员工表探测结果(Task 0 已执行,2026-08-05)
微信员工表的交互与 WhatsApp **不同**——以**跳转**为主,**无数字→modal**:
| 元素 | 行为 | 目标 |
|---|---|---|
| 好友数(数字) | 跳转 | `/wechatdetail/1/{wxId}` —— 该微信号客户列表(owner=wxId) |
| 群数(数字) | 跳转 | `/wechatdetail/2/{wxId}` —— 该微信号群列表 |
| 昵称 | 新 tab | `salewechat/message/wechat-window/...` 微信聊天记录 |
| 手机号 | 新 tab | `customer/userDetailNew?userId=...` 员工详情 |
| 主/副标记 | 无明显效果 | — |
| 消息数/沟通数/及时回复 | **不可点** | — |
**关键差异**:WhatsApp 用 modal(添加人数/沟通人数);微信用跳转(好友数/群数)。故 Task 7(WhatsApp modal)和 Task 8(微信跳转)实现方式不同。
---
## 后续 Plan(不在本 plan 范围)
- Plan 2: CustomerList 列+交互对齐(phase 0 采集后写)
- Plan 3: GroupList / CallList / SmsList
- Plan 4: ChatTimeline 会话列表 + `/api/sessions` 聚合端点
- Plan 5: SearchView + AccountList 删除