# 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/_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/_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/_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 ... -> , 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> { return getJson>( '/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 ``` - [ ] **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 `