From 1d815d84b9001aefe250e34f0c5e98ac831b19e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 5 Aug 2026 18:30:12 +0800 Subject: [PATCH] docs: UI alignment spec + StaffList interaction plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec: align all list/detail pages to 云客 (columns + interactions); covers interaction taxonomy, backend extensions (customer add_time/filters, /api/sessions), phase-0 capture. Plan 1: StaffList interaction closure + customer backend + YkStaffDetailModal. Co-Authored-By: Claude --- .gitignore | 1 + .../2026-08-05-yunke-stafflist-interaction.md | 726 ++++++++++++++++++ .../2026-08-05-yunke-ui-alignment-design.md | 141 ++++ 3 files changed, 868 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md create mode 100644 docs/superpowers/specs/2026-08-05-yunke-ui-alignment-design.md diff --git a/.gitignore b/.gitignore index 3fe08d0..4b5b46c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ media/ *.db *.sqlite *.sqlite3 +.playwright-mcp/ diff --git a/docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md b/docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md new file mode 100644 index 0000000..373572c --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md @@ -0,0 +1,726 @@ +# 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 `