diff --git a/docs/superpowers/plans/2026-08-25-yunke-staff-chat-window.md b/docs/superpowers/plans/2026-08-25-yunke-staff-chat-window.md new file mode 100644 index 0000000..320eda1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-yunke-staff-chat-window.md @@ -0,0 +1,1002 @@ +# 云客员工聊天窗口对齐(StaffChat 五视图)实施计划 + +> **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:** 新建 `/staff-chat/:ownerId/:tab/:talkerId?` 页面,完整对齐云客员工聊天窗口的 5 个视图(单聊/群聊/朋友圈/通讯录/统计分析),含数据抓取与 API。 + +**Architecture:** 方案 A——独立新页 `StaffChat.vue` + 5 视图子组件(`yunkes/src/components/staffchat/`),旧 `ChatTimeline.vue` 保留给 WA 不动。后端新增 `moments_post` 表+`moments` 抓取实体、`chat_group` 补列、消息类型归一化补全+回填、`/api/moments`+`/api/staff-stats` 两个新端点、sessions/accounts/messages 增量改动。全部本地数据,不访问云客(抓取器除外)。 + +**Tech Stack:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0 async + alembic + Playwright 页面抓取;前端 Vue3 + TS + Element Plus;测试 pytest(`make_client` fixture)/ vitest。 + +**Spec:** `docs/superpowers/specs/2026-08-25-yunke-staff-chat-window-design.md`(含云客真机接口事实 §2,实现时对齐口径以它为准) + +**约定(全任务适用):** +- 后端测试命令:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -v`(Windows Git Bash) +- 前端测试/构建:`cd yunkes && npm run test 2>/dev/null || npx vitest run `;类型检查 `npx vue-tsc --noEmit` +- 每任务一 commit,消息格式沿用仓库惯例(`feat(chat): ...` 中文描述) +- alembic 迁移文件名随机 hex 由 `alembic revision` 生成,plan 中用占位名 +- **TDD**:先写测试看失败,再实现看通过 + +--- + +## Phase 1 后端 + +### Task 1: `moments_post` 表 + repo upsert + +**Files:** +- Modify: `yunke-server/app/db/models.py`(文件末尾附近、`GroupMember` 之后加模型) +- Create: `yunke-server/migrations/versions/_moments_post_table.py` +- Modify: `yunke-server/app/storage/repos.py`(加 `upsert_moments_posts`) +- Test: `yunke-server/tests/test_models.py`(追加) + +- [ ] **Step 1: 写失败测试**(`tests/test_models.py` 追加) + +```python +async def test_moments_post_model_roundtrip(db_session): # fixture 沿用该文件现有 db_session 惯例 + from app.db.models import MomentsPost + row = MomentsPost( + id="69e082a3c3524a7fe3ac53b4", wechat_id="wxid_x", sns_id="-3546042415332912522", + author_name="Yoyo", author_wxid="wxid_x", author_avatar="https://wx.qlogo.cn/...", + content="Welcome to HESU", type_raw="1", create_time=1776302051000, + praise_json=[{"name": "E**U"}], comment_json=[], image_urls=["https://wx.oss.../a.jpg"], + raw_json={"id": "69e082a3c3524a7fe3ac53b4"}, + ) + db_session.add(row) + await db_session.commit() + got = (await db_session.execute(sa.select(MomentsPost))).scalar_one() + assert got.praise_json[0]["name"] == "E**U" + assert got.create_time == 1776302051000 +``` + +(若 `test_models.py` 无 `db_session` fixture,参照其现有的建表/会话模式补一个;conftest 只有 `make_client`,模型级测试在该文件里已有先例,遵循它。) + +- [ ] **Step 2: 跑测试确认失败** + +Run: `.venv/Scripts/python.exe -m pytest tests/test_models.py -v -k moments` +Expected: FAIL `ImportError: cannot import name 'MomentsPost'` + +- [ ] **Step 3: 加模型**(`models.py`,字段对齐 spec §4.1;注意 **create_time 是 ms**,云客秒×1000 在 scraper 做) + +```python +class MomentsPost(Base): + """员工朋友圈帖子 — source: ``POST /wechatweb/wechatFriendster/getFriendsterInfo``. + + PK = 云客 data.id。create_time 统一 ms epoch(云客返回秒,scraper ×1000)。 + """ + + __tablename__ = "moments_post" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + wechat_id: Mapped[str] = mapped_column(String(128), index=True) + sns_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + author_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + author_wxid: Mapped[str | None] = mapped_column(String(128), nullable=True) + author_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + type_raw: Mapped[str | None] = mapped_column(String(16), nullable=True) + create_time: Mapped[int | None] = mapped_column(BigInteger, index=True) + praise_json: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True) + comment_json: Mapped[list[dict[str, Any]] | None] = mapped_column(JSON, nullable=True) + image_urls: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) + raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + fetched_at: Mapped[dt.datetime] = _fetched_at() + updated_at: Mapped[dt.datetime] = _updated_at() +``` + +- [ ] **Step 4: 生成迁移并跑**(沿用现有迁移惯例,参照 `3892dbfab839_customer_add_time.py` 的写法) + +```bash +.venv/Scripts/python.exe -m alembic revision --autogenerate -m "moments_post table" +# autogenerate 不可用则手写 op.create_table +.venv/Scripts/python.exe -m alembic upgrade head +``` +Expected: `Running upgrade ... -> , moments_post table`。注意迁移需同时兼容空库(tests 用 `Base.metadata.create_all`,不依赖迁移,但 MySQL 生产走迁移)。 + +- [ ] **Step 5: repo upsert**(`repos.py`,镜像 `upsert_customers` 模式) + +```python +async def upsert_moments_posts(session: AsyncSession, recs: list[dict[str, Any]]) -> int: + """Batch idempotent upsert for :class:`MomentsPost` (PK = ``id``).""" + for rec in recs: + await _upsert_one(session, MomentsPost, rec) + return len(recs) +``` + +(`_upsert_one` 的列白名单机制若按类自动取列则无需注册;若存在显式 `_INT_FIELDS`/列集合,补 `create_time` 到 int 强转集合。检查 `repos.py:95-110` 现有集合。) + +- [ ] **Step 6: 跑全部模型测试通过** + +Run: `.venv/Scripts/python.exe -m pytest tests/test_models.py tests/test_repos.py -v` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add yunke-server/app/db/models.py yunke-server/app/storage/repos.py yunke-server/migrations/ yunke-server/tests/test_models.py +git commit -m "feat(db): moments_post 表+upsert(朋友圈实体,PK=云客data.id,create_time统一ms)" +``` + +--- + +### Task 2: `moments` 抓取实体(页面 fetch) + +**Files:** +- Modify: `yunke-server/scripts/scrape_via_page.py`(加 `_normalize_moments` + `scrape_moments` + CLI 注册,~1870 行后) +- Modify: `yunke-server/scripts/full_sync_all.sh`(media 步之前加一步) +- Test: `yunke-server/tests/test_scrape_moments.py`(新) + +云客接口事实(spec §2.2):`POST /wechatweb/wechatFriendster/getFriendsterInfo`,form-data `startTime/endTime(空串)/pageNum/pageSize/wechatId`;响应 `data.data[]`(`id/snsId/friendsterName/friendsterWXId/friendsterWXIdImg/content/createTime(秒)/type/praise[]/comment[]/imgUrlsList[]`)+ `data.hashNext`。 + +- [ ] **Step 1: 写失败测试**(fixture 用真机 JSON 片段) + +```python +"""scrape_moments 归一化 + 抓取循环测试(不启浏览器,mock _fetch_pages 侧的 HTTP)。""" +import asyncio +from unittest.mock import AsyncMock, patch + +from scripts.scrape_via_page import _normalize_moments + +RAW = { # 2026-08-25 真机抓包样本(截取关键字段) + "id": "69e082a3c3524a7fe3ac53b4", "wechatId": "wxid_wo97u1u2x11w22", + "snsId": "-3546042415332912522", "friendsterName": "Yoyo-HESU", + "friendsterWXId": "wxid_wo97u1u2x11w22", + "friendsterWXIdImg": "https://wx.qlogo.cn/mmhead/ver_1/vsHn.../0", + "content": "Welcome to HESU", "type": "1", "createTime": "1776302051", # 秒! + "praise": [{"name": "E**U", "praiseId": "wxid***r822", "praiseImg": "https://wx.qlogo.cn/p/0"}], + "comment": [], + "imgUrlsList": ["https://wx.oss.yunkecn.com/friendstr/8m0atd/16/b1/ea/xx.jpg"], +} + + +def test_normalize_moments_converts_seconds_to_ms(): + rec = _normalize_moments(RAW) + assert rec["id"] == "69e082a3c3524a7fe3ac53b4" + assert rec["create_time"] == 1776302051 * 1000 # 秒→ms + assert rec["author_name"] == "Yoyo-HESU" + assert rec["praise_json"][0]["name"] == "E**U" + assert rec["image_urls"] == RAW["imgUrlsList"] + assert rec["raw_json"]["snsId"] == "-3546042415332912522" + + +def test_normalize_moments_null_safe(): + rec = _normalize_moments({}) + assert rec["id"] is None # 缺 id 行由调用方丢弃 +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `.venv/Scripts/python.exe -m pytest tests/test_scrape_moments.py -v` +Expected: FAIL `ImportError: cannot import name '_normalize_moments'` + +- [ ] **Step 3: 实现 `_normalize_moments` + `scrape_moments`** + +```python +def _normalize_moments(record: dict[str, Any]) -> dict[str, Any]: + """云客朋友圈行 → moments_post 记录。createTime 秒 → ms(×1000)。缺 id 返回 id=None 由调用方丢。""" + ct = record.get("createTime") + try: + ct_ms = int(ct) * 1000 if ct is not None else None + except (TypeError, ValueError): + ct_ms = None + return { + "id": record.get("id"), + "wechat_id": record.get("wechatId"), + "sns_id": record.get("snsId"), + "author_name": record.get("friendsterName"), + "author_wxid": record.get("friendsterWXId"), + "author_avatar": record.get("friendsterWXIdImg"), + "content": record.get("content"), + "type_raw": str(record.get("type")) if record.get("type") is not None else None, + "create_time": ct_ms, + "praise_json": record.get("praise") or [], + "comment_json": record.get("comment") or [], + "image_urls": record.get("imgUrlsList") or [], + "raw_json": record, + } + + +async def scrape_moments(cookie_str: str, page_size: int = 100, max_pages_per_staff: int = 50): + """逐员工抓朋友圈(浏览器 fetch getFriendsterInfo,hashNext 翻页)。返回统计 dict。""" + # 结构完全镜像 scrape_wa_groups(browser fetch + form 翻页): + # 1) 启动浏览器上下文(复用 _browser_context helper,参照 scrape_wa_groups 开头) + # 2) SELECT wx_id FROM wechat_account WHERE channel 条件(表本身仅微信,全量遍历) + # 3) 每员工循环 pageNum=1..N:fetch POST form {startTime:"", endTime:"", pageNum, pageSize, wechatId} + # resp["data"]["data"] → _normalize_moments → 丢 id=None 行 → 累积 + # resp["data"]["hashNext"] 为 False 即止;页间 asyncio.sleep(1) + # 4) 批量 upsert_moments_posts(async_session 引用 scrape_wa_groups 的 DB 打开方式) + ... +``` + +**实现要点**(执行者抄 `scrape_wa_groups` 1326-1384 行为模板):form 字段名精确为 `startTime/endTime/pageNum/pageSize/wechatId`(云客 form-data 小驼峰);URL `https://crm2.yunkecn.com/wechatweb/wechatFriendster/getFriendsterInfo`;每员工结束打 log `DONE moments +N`。 + +- [ ] **Step 4: CLI 注册**:`choices` 列表加 `"moments", # 员工朋友圈 (browser fetch getFriendsterInfo)`;dispatch 加分支 `elif args.entity == "moments": await scrape_moments(cookie, args.max_pages)`(复用 `--max-pages` 参数语义为"每员工翻页上限")。 + +- [ ] **Step 5: 抓取循环单测**(mock 浏览器 fetch 层——若 `_fetch_pages` 不适合本实体(它是通用翻页),则把 scrape_moments 的翻页内核拆一个 `_iter_moments_pages(fetch, wechat_id, page_size)` 纯函数接受 fetch 回调,单测喂两页样本断言翻页终止与累积) + +- [ ] **Step 6: 跑测试 + 手动冒烟(可选,需 .env cookie)** + +Run: `.venv/Scripts/python.exe -m pytest tests/test_scrape_moments.py -v` → PASS +可选真机:`python scripts/scrape_via_page.py --entity moments`,检查 `moments_post` 行数 > 0 + +- [ ] **Step 7: full_sync_all.sh 加步**(media 步前):`echo "== moments ==" && python scripts/scrape_via_page.py --entity moments || true`(沿用脚本内既有步骤格式) + +- [ ] **Step 8: Commit** + +```bash +git add yunke-server/scripts/scrape_via_page.py yunke-server/scripts/full_sync_all.sh yunke-server/tests/test_scrape_moments.py +git commit -m "feat(scraper): moments 实体(浏览器fetch getFriendsterInfo,秒→ms,hashNext翻页,接入full_sync)" +``` + +--- + +### Task 3: `chat_group` 补列(群主/好友数/建群时间)+ 存量回填 + groups API + +**Files:** +- Modify: `yunke-server/app/db/models.py`(`ChatGroup` 加 6 列) +- Create: `yunke-server/migrations/versions/_chat_group_owner_cols.py` +- Create: `yunke-server/scripts/backfill_chat_group_fields.py` +- Modify: `yunke-server/app/api/routers/groups.py`(`GroupOut` 输出新列;schemas.py 同步) +- Test: `yunke-server/tests/test_api_groups.py`(追加) + +raw_json 键(真机验证):`ownerName/ownerWchatId/ownerAlias/ownerImage/friendCount/createTime`(createTime ms 已是)。 + +- [ ] **Step 1: 写失败测试**(`test_api_groups.py` 追加;`make_client` seed 一个带 raw_json 的 ChatGroup) + +```python +async def test_groups_owner_fields(make_client): + async def seed(session): + session.add(ChatGroup(room_id="123@chatroom", channel="wechat", room_name="测试群", + owner_account_id="wxid_x", member_count=10, + raw_json={"ownerName": "Q**e", "ownerWchatId": "RL3*****108", "ownerAlias": "Fr******er", + "ownerImage": "https://wx.qlogo.cn/o/0", "friendCount": 22, "createTime": 1740000000000})) + await session.commit() + client = await make_client(seed) + r = await client.get("/api/groups", params={"room_id": "123@chatroom"}) + item = r.json()["items"][0] + assert item["owner_name"] == "Q**e" + assert item["owner_wxid"] == "RL3*****108" + assert item["friend_count"] == 22 + assert item["create_time"] == 1740000000000 +``` + +- [ ] **Step 2: 跑测试确认失败**(`KeyError: 'owner_name'`——schema 无此字段) + +- [ ] **Step 3: 模型加列**(`ChatGroup`,nullable 全部) + +```python + # 通讯录·微信群 tab 对齐云客的补充列(2026-08-25,从 raw_json 提取) + owner_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # 群主昵称(上游脱敏) + owner_wxid: Mapped[str | None] = mapped_column(String(128), nullable=True) + owner_alias: Mapped[str | None] = mapped_column(String(255), nullable=True) # 群主微信号 + owner_avatar: Mapped[str | None] = mapped_column(String(1024), nullable=True) + friend_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 群成员中员工好友数 + create_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 建群时间 ms +``` + +同时:`yunke-server/app/scraper/wechat.py` 的 `_normalize_chat_group`(864 行)加同名字段提取(`record.get("ownerName")` 等),保证增量抓取持续写入。 + +- [ ] **Step 4: alembic 迁移**(`op.add_column` ×6,镜像 `3892dbfab839_customer_add_time.py` 写法)+ `upgrade head` + +- [ ] **Step 5: 回填脚本** `scripts/backfill_chat_group_fields.py`——镜像 `backfill_customer_add_time.py` 模式:SELECT 全部群,`raw_json` 缺列则提取 UPDATE;幂等;末尾打印 `backfilled N/M rows` + +- [ ] **Step 6: schemas.py `GroupOut` 加 6 字段 + groups.py 路由输出**(该路由是显式构建 dict 的模式——找到构建处加键即可) + +- [ ] **Step 7: 跑测试 + 回填存量** + +Run: `.venv/Scripts/python.exe -m pytest tests/test_api_groups.py -v` → PASS +Run: `.venv/Scripts/python.exe scripts/backfill_chat_group_fields.py` → `backfilled ~870/876 rows` + +- [ ] **Step 8: Commit** + +```bash +git add -A yunke-server +git commit -m "feat(groups): chat_group补群主/好友数/建群时间6列(raw_json提取)+回填+API输出" +``` + +--- + +### Task 4: 消息类型归一化判别 spike + 映射扩展 + 存量回填 + messages API 过滤 + +**Files:** +- Modify: `yunke-server/scripts/scrape_via_page.py:972`(`_normalize_msg_type`) +- Create: `yunke-server/scripts/backfill_msg_types.py` +- Modify: `yunke-server/app/api/routers/messages.py`(`msg_type` + `q` 参数) +- Test: `yunke-server/tests/test_api_messages.py`(追加 msg_type/q 用例);`yunke-server/tests/test_scrape_msg_type.py`(新,映射单测) + +- [ ] **Step 1: Spike——判别 unknown 大头语义**(交互式 SQL 抽样,**产出写进本文件底部"判别记录"**) + +```bash +.venv/Scripts/python.exe -c " +import sqlite3, json +conn = sqlite3.connect('smoke.db') +for tr in (21, 15, 9, 10, 4, 18, 8, 6, 22, 13, 14, 5, 11, 20, 10000, 7): + row = conn.execute(\"SELECT text, raw_json FROM message WHERE channel='wechat' AND type_raw=? AND text IS NOT NULL AND length(text)>0 LIMIT 1\", (tr,)).fetchone() + t = (row[0][:80] if row else 'N/A') + print(f'type_raw={tr}: {t}') +" +``` + +按样本判读(微信惯例 + 云客 `raw_json.type` 子码双重印证:49 行看 `raw_json.type`,10=链接 21=? 2001=红包 2000=转账 33=小程序 6=文件 19=合并转发…)。**判别结论填入下方表格后再写映射**: + +| type_raw | 样本摘要 | 判定 | +|---|---|---| +| 21 | (spike 填) | ? | +| ... | | | + +- [ ] **Step 2: 写映射单测**(`test_scrape_msg_type.py`,含 49 红包/转账拆分) + +```python +from scripts.scrape_via_page import _normalize_msg_type + +def test_basic_mapping_unchanged(): + assert _normalize_msg_type("1") == "text" + assert _normalize_msg_type("2") == "image" + assert _normalize_msg_type("34") == "voice" + assert _normalize_msg_type("43") == "video" + +def test_new_mappings(): # 具体期望值以 Step 1 判别为准,此处为占位断言 + assert _normalize_msg_type("21") != "unknown" + # ... 每个 spike 判别出的 code 一条断言 + +def test_type_49_redpacket_vs_transfer(): + # 49 拆分需要行级信息(paymsgid/appmsg type),映射函数升级为可带 row: + assert _normalize_msg_type("49", row={"paymsgid": "x"}) in ("red_packet", "transfer") + assert _normalize_msg_type("49", row={"text": "2001"}) == "red_packet" +``` + +- [ ] **Step 3: 跑测试确认失败 → 扩展实现** + +`_normalize_msg_type(raw_type, row=None)`:保留现有 7 项映射;按 spike 结论补 21/15/9/10/4/18/8/6/22/13/14/5/11/20(候选语义:链接 link/视频通话 video_call/语音通话 voice_call/文件 file/表情 emoji/位置 location/名片 card/系统 system——**以样本为准,不猜**);49 特判:`row.paymsgid` 非空或 text 含 `>2001<` → `red_packet`,`>2000<` → `transfer`,其余 49 → 现有 `file`。调用点(735/827 行)把整个 row 传入。 + +- [ ] **Step 4: 回填脚本** `backfill_msg_types.py`——SELECT `type='unknown' OR type IS NULL` 分批(5000/批),逐行调新 `_normalize_msg_type` UPDATE;幂等;打印各 type 计数。**注意 SQLite 参数上限,复用分批模式。** + +- [ ] **Step 5: messages API 加参数**(`messages.py`,`msg_type: str | None` + `q: str | None`) + +```python + if msg_type is not None: + # 前端 10 类下拉 → 本地 type 集合(云客"图片"含新旧图,"文本"=text) + _MSG_TYPE_FILTER = { + "text": ("text",), "image": ("image", "image_legacy"), "voice": ("voice",), + "video": ("video",), "link": ("link",), "file": ("file",), + "red_packet": ("red_packet",), "transfer": ("transfer",), + "voice_call": ("voice_call",), "video_call": ("video_call",), + } + conditions.append(Message.type.in_(_MSG_TYPE_FILTER[msg_type])) + if q is not None: + conditions.append(Message.text.like(f"%{q}%")) +``` + +(值域校验:未知 msg_type 返回 422——Literal 或手动检查;`q` 走 LIKE,MySQL/SQLite 双方言一致。) + +- [ ] **Step 6: API 测试**(`test_api_messages.py` 追加:seed text+image+red_packet 各一条,断言 `msg_type=image` 只回 image;`q=你好` 只回含词行) + +- [ ] **Step 7: 全量验证 + 回填存量** + +Run: `pytest tests/test_api_messages.py tests/test_scrape_msg_type.py -v` → PASS +Run: `python scripts/backfill_msg_types.py` → unknown 从 ~87k 降到纯未知残余,打印计数 + +- [ ] **Step 8: Commit** + +```bash +git add -A yunke-server +git commit -m "feat(msg): 类型归一化扩展(spike判别+49拆红包/转账)+存量回填+API msg_type/q过滤" +``` + +--- + +### Task 5: sessions API `is_del`/`pinned` + accounts API `avatar` + +**Files:** +- Modify: `yunke-server/app/api/routers/sessions.py`(`is_del` 参数 + join customer.delete_flag + pinned) +- Modify: `yunke-server/app/api/schemas.py`(`SessionOut.pinned`;`AccountOut.avatar`) +- Modify: `yunke-server/app/api/routers/organization.py`(accounts 构建 dict 处提取 avatar) +- Test: `yunke-server/tests/test_api_sessions.py`(追加);`test_api_organization.py`(追加 avatar) + +- [ ] **Step 1: 写失败测试** + +sessions(追加): +```python +async def test_sessions_is_del_filter(make_client): + async def seed(session): + # 员工 wxid_o 两个好友:delete_flag=0/1,各有一条消息 + session.add(Customer(friend_id="f1", channel="wechat", owner_account_id="wxid_o", delete_flag=0)) + session.add(Customer(friend_id="f2", channel="wechat", owner_account_id="wxid_o", delete_flag=1)) + session.add(Message(msg_id="m1", channel="wechat", owner_account_id="wxid_o", talker_id="f1", timestamp=1)) + session.add(Message(msg_id="m2", channel="wechat", owner_account_id="wxid_o", talker_id="f2", timestamp=2)) + await session.commit() + client = await make_client(seed) + live = await client.get("/api/sessions", params={"owner_account_id": "wxid_o", "channel": "wechat", "is_del": 0}) + deleted = await client.get("/api/sessions", params={"owner_account_id": "wxid_o", "channel": "wechat", "is_del": 1}) + assert [s["talker_id"] for s in live.json()["sessions"]] == ["f1"] + assert [s["talker_id"] for s in deleted.json()["sessions"]] == ["f2"] + + +async def test_sessions_pinned_filehelper(make_client): + # filehelper 会话返回 pinned=True(对齐云客置顶区) + ...seed 一条 talker_id="filehelper" 消息... + r = await client.get("/api/sessions", ...) + fh = next(s for s in r.json()["sessions"] if s["talker_id"] == "filehelper") + assert fh["pinned"] is True +``` + +organization(追加): +```python +async def test_accounts_avatar_from_raw_json(make_client): + # wechat_account raw_json.headImageUrl → avatar 字段;WA 行 raw_json 无该键 → None + ... +``` + +- [ ] **Step 2: 确认失败 → 实现** + +sessions.py 实现要点: +- `is_del: Annotated[int | None, Query()] = None` 参数;`is_del=0` 时排除 `delete_flag=1` 的 talker(注意 NULL 视为 0:`sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0)`);`is_del=1` 时只要 `delete_flag=1` +- join 方式:现有 `_meta` 已查 customer——把 delete_flag 一并带出,在 Python 侧过滤(比 SQL LEFT JOIN 简单且 `_meta` 本来就每 talker 一行;注意**无 customer 行的 talker(如 filehelper)**在 is_del=0 时保留、is_del=1 时排除) +- `pinned`:`sessions.append(SessionOut(..., pinned=(r.talker_id == "filehelper")))`;群不置顶 + +organization.py:accounts 构建 dict 处(`AccountOut` 显式构建)加 +`avatar=(row.raw_json or {}).get("headImageUrl") if channel=='wechat' else None`(WA 的 raw_json 有无头像键以实际为准,有就同样取)。 + +- [ ] **Step 3: 跑测试通过 → Commit** + +```bash +git add yunke-server/app/api/routers/sessions.py yunke-server/app/api/routers/organization.py yunke-server/app/api/schemas.py yunke-server/tests/ +git commit -m "feat(api): sessions加is_del过滤+filehelper置顶标记,accounts加avatar(raw_json提取)" +``` + +--- + +### Task 6: `GET /api/moments` 端点 + +**Files:** +- Create: `yunke-server/app/api/routers/moments.py` +- Modify: `yunke-server/app/api/schemas.py`(`MomentsPostOut`/`MomentsListOut`);`yunke-server/app/api/main.py`(注册 router) +- Test: `yunke-server/tests/test_api_moments.py` + +- [ ] **Step 1: 写失败测试** + +```python +async def test_moments_list_desc_with_header(make_client): + async def seed(session): + session.add(WechatAccount(wx_id="wxid_o", nick_name="员工昵称", + raw_json={"headImageUrl": "https://wx.qlogo.cn/a/0"})) + session.add(MomentsPost(id="p1", wechat_id="wxid_o", content="新帖", create_time=200)) + session.add(MomentsPost(id="p2", wechat_id="wxid_o", content="旧帖", create_time=100)) + session.add(MomentsPost(id="p3", wechat_id="wxid_other", content="别人", create_time=300)) + await session.commit() + client = await make_client(seed) + r = await client.get("/api/moments", params={"wechat_id": "wxid_o", "page": 1, "page_size": 10}) + body = r.json() + assert body["header"] == {"nickname": "员工昵称", "avatar": "https://wx.qlogo.cn/a/0", "alias": None} + assert [p["id"] for p in body["items"]] == ["p1", "p2"] # desc + assert body["total"] == 2 + + +async def test_moments_empty_staff(make_client): + # 无帖子: header 仍返回账号信息, items 空 + ... +``` + +- [ ] **Step 2: 确认失败 → 实现** + +```python +"""GET /api/moments — 员工朋友圈帖子列表(create_time desc)+账号头部。全本地。""" +router = APIRouter(prefix="/api", tags=["moments"]) + +@router.get("/moments") +async def list_moments(session: SessionDep, wechat_id: Annotated[str, Query(min_length=1)], + page: PageParam = 1, page_size: PageSizeParam = 20) -> MomentsListOut: + base = [MomentsPost.wechat_id == wechat_id] + total = (await session.execute(sa.select(sa.func.count()).select_from(MomentsPost).where(*base))).scalar_one() + rows = (await session.execute( + sa.select(MomentsPost).where(*base).order_by(MomentsPost.create_time.desc().nulls_last()) + .offset((page - 1) * page_size).limit(page_size))).scalars().all() + acc = (await session.execute(sa.select(WechatAccount).where(WechatAccount.wx_id == wechat_id))).scalar_one_or_none() + header = {"nickname": acc.nick_name if acc else None, + "avatar": (acc.raw_json or {}).get("headImageUrl") if acc else None, + "alias": acc.alias if acc else None} + return MomentsListOut(header=header, total=total, items=[MomentsPostOut.model_validate(r, from_attributes=True) for r in rows]) +``` + +(`nulls_last` 在 MySQL 会被 compat 补丁剥掉但 DESC 语义等价——2026-08-22 已验证,直接用。`PageParam/PageSizeParam` 沿用 messages.py 的 Annotated 别名。) + +- [ ] **Step 3: main.py 注册 `app.include_router(moments.router)` → 跑测试通过 → Commit** + +```bash +git add yunke-server/app/api/routers/moments.py yunke-server/app/api/schemas.py yunke-server/app/api/main.py yunke-server/tests/test_api_moments.py +git commit -m "feat(api): GET /api/moments 朋友圈列表(desc分页+账号头部)" +``` + +--- + +### Task 7: `GET /api/staff-stats` 端点(7 类计数 + 分型明细) + +**Files:** +- Create: `yunke-server/app/api/routers/staff_stats.py` +- Modify: `yunke-server/app/api/schemas.py`(计数/明细 schema);`main.py` 注册 +- Test: `yunke-server/tests/test_api_staff_stats.py` + +口径(spec §4.5,全部本地聚合): +- 计数 `GET /api/staff-stats?wechat_id=&start=&end=` → `{new_customers, new_groups, red_transfer, del_customers, blocked_customers, voice_calls, sensitive_words: 0}` +- 明细 `&kind=new_customers|new_groups|red_transfer|del_customers|blocked_customers|voice_calls` → 分型行(每 kind 字段不同) +- **语音通话必须 wxId→userId 转换**:`wechat_account.user_id` join 后查 `phone_call.user_id`(直查 wxId 恒 0 行——实测坑) +- 红包转账:`type_raw=49 AND type IN ('red_packet','transfer')`(Task 4 回填后) +- 新客户:`add_time` 窗内 OR(add_time IS NULL AND 该对话首条消息在窗内) + +- [ ] **Step 1: 写失败测试**(核心口径各一条 + 转换坑一条) + +```python +async def test_staff_stats_counts_and_voice_user_id_join(make_client): + async def seed(session): + session.add(WechatAccount(wx_id="wxid_o", user_id="U32HEX")) + # 新客户: add_time 窗内 1 + 兜底首消息窗内 1 + session.add(Customer(friend_id="c1", channel="wechat", owner_account_id="wxid_o", add_time=1_800_000_000_000)) + session.add(Customer(friend_id="c2", channel="wechat", owner_account_id="wxid_o", add_time=None)) + session.add(Message(msg_id="m1", channel="wechat", owner_account_id="wxid_o", talker_id="c2", timestamp=1_800_000_000_001)) + # 红包(回填后 type) + session.add(Message(msg_id="m2", channel="wechat", owner_account_id="wxid_o", talker_id="c1", + timestamp=1_800_000_000_002, type_raw=49, type="red_packet", text="2001")) + # 语音通话: user_id 是 32HEX 非 wxId! + session.add(PhoneCall(id="pc1", channel="voice_call", user_id="U32HEX", + start_call_time=1_800_000_000_003, call_number="c1")) + await session.commit() + client = await make_client(seed) + r = await client.get("/api/staff-stats", params={ + "wechat_id": "wxid_o", "start": 1_700_000_000_000, "end": 1_900_000_000_000}) + body = r.json() + assert body["new_customers"] == 2 # add_time + 首消息兜底 + assert body["red_transfer"] == 1 + assert body["voice_calls"] == 1 # 直查 wxid 会得 0 —— join user_id + + +async def test_staff_stats_detail_red_transfer_fields(make_client): + # kind=red_transfer 行含 op_type/amount/time/msg_id(前端定位用) + ... + + +async def test_staff_stats_detail_new_customers_link_fields(make_client): + # kind=new_customers 行含 talker_id/nickname(可点跳聊天) + ... +``` + +- [ ] **Step 2: 确认失败 → 实现**(`staff_stats.py` 镜像 `staff_chat_stats.py` 结构:SessionDep + 分节聚合函数) + +```python +async def _voice_calls(session, wxid, start, end, detail: bool): + row = (await session.execute(sa.select(WechatAccount.user_id).where(WechatAccount.wx_id == wxid))).scalar_one_or_none() + conds = [PhoneCall.channel == "voice_call", PhoneCall.start_call_time >= start, PhoneCall.start_call_time <= end] + if row: # 关键: user_id 是 32HEX 员工ID,不是 wxId + conds.append(PhoneCall.user_id == row) + ... +``` + +明细行 schema(按 kind 分型,`extra="allow"` 或 Union;推荐统一 `rows: list[dict]`——前端按 kind 配置列,后端只管字段名稳定):`new_customers`→`{talker_id,nickname,avatar,remark,time}`;`red_transfer`→`{op_type,talker_id,is_room,room_id,nickname,amount,time,msg_id}`(金额从 text XML ``/`` 正则提取,失败 None);`new_groups`→`{room_id,room_name,time}`;`del/blocked_customers`→`{talker_id,nickname,time}`(time=updated_at 毫秒);`voice_calls`→`{id,peer,type,incoming,connected,duration,start_time,media_url}`(列对齐 CallList)。 + +窗口默认(不带 start/end 时):近 7 天(对齐云客统计默认)。 + +- [ ] **Step 3: 注册 + 跑测试通过 → Commit** + +```bash +git add yunke-server/app/api/routers/staff_stats.py yunke-server/app/api/schemas.py yunke-server/app/api/main.py yunke-server/tests/test_api_staff_stats.py +git commit -m "feat(api): GET /api/staff-stats 7类计数+分型明细(voice走user_id join,新客户add_time兜底,红包金额提取)" +``` + +--- + +## Phase 2 前端骨架 + 单聊 + +### Task 8: 还原旧页 WIP + 路由 + API client + 工具函数 + +**Files:** +- Modify: `yunkes/src/views/ChatTimeline.vue`(还原未提交 WIP:删 `ownerPhone/ownerAvatar/sidebarProfile` 三处 + 模板 `:staff-profile` 行) +- Modify: `yunkes/src/api/index.ts`(还原 `delete_flag` 行;新增 getMoments/getStaffStats/getSessions 参数扩展) +- Modify: `yunkes/src/api/types.ts`(MomentsPost/MomentsList/StaffStats/ChatSession.pinned/Account.avatar) +- Modify: `yunkes/src/router/index.ts`(staff-chat 路由) +- Modify: `yunkes/src/utils/format.ts`(`relativeChatTime`) + +- [ ] **Step 1: 还原两个文件的 WIP** + +```bash +cd /d/refactor/yunke && git checkout -- yunkes/src/views/ChatTimeline.vue yunkes/src/api/index.ts +git status # 确认这两文件回到干净 +``` + +- [ ] **Step 2: 路由**(router/index.ts,chat 路由块后加) + +```ts + { + // 员工聊天窗口(对齐云客 wechat-window 五视图): tab=single|group|moments|contacts|stats + // tab=group 时 talkerId 语义为 roomId; ?msgid= 定位消息(红包转账明细跳转) + path: '/staff-chat/:ownerId/:tab(single|group|moments|contacts|stats)?/:talkerId?', + name: 'staff-chat', + component: () => import('@/views/StaffChat.vue'), + props: true, + meta: { title: '员工聊天窗口' }, + }, +``` + +- [ ] **Step 3: types.ts 追加** + +```ts +export interface ChatSession { pinned?: boolean } // 追加字段到现有 interface + +export interface MomentsPost { + id: string; wechat_id: string; content?: string | null; type_raw?: string | null + create_time?: number | null; author_name?: string | null; author_avatar?: string | null + praise_json?: { name?: string }[] | null; comment_json?: unknown[] | null + image_urls?: string[] | null +} +export interface MomentsList { header: { nickname: string | null; avatar: string | null; alias: string | null }; total: number; items: MomentsPost[] } +export interface StaffStatsCounts { new_customers: number; new_groups: number; red_transfer: number; sensitive_words: number; del_customers: number; blocked_customers: number; voice_calls: number } +export interface StaffStatsDetail { rows: Record[] } +``` + +- [ ] **Step 4: api/index.ts 追加**(getJson/clean 模式与现有一致) + +```ts +export interface SessionQuery { owner_account_id: string; channel?: Channel; is_del?: number } +// getSessions 传参加 is_del: query.is_del + +export function getMoments(query: { wechat_id: string; page?: number; page_size?: number }): Promise { + return getJson('/moments', clean({ wechat_id: query.wechat_id, page: query.page, page_size: query.page_size })) +} + +export function getStaffStatsCounts(query: { wechat_id: string; start?: number; end?: number }): Promise { + return getJson('/staff-stats', clean({ wechat_id: query.wechat_id, start: query.start, end: query.end })) +} + +export function getStaffStatsDetail(query: { wechat_id: string; kind: string; start?: number; end?: number; page?: number; page_size?: number }): Promise { + return getJson('/staff-stats', clean({ ...query })) +} +``` + +(`getMessages` 的 query 类型补 `msg_type?: string; q?: string`。) + +- [ ] **Step 5: format.ts 加 `relativeChatTime`**(云客相对时间格式) + +```ts +/** 云客聊天窗口相对时间: 今天→HH:mm, 昨天→'昨天', 今年→M月D日, 往年→YYYY年MM月DD日 */ +export function relativeChatTime(ms: number): string { + const d = new Date(ms), now = new Date() + const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString() + const yest = new Date(now); yest.setDate(now.getDate() - 1) + if (sameDay(d, now)) return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + if (sameDay(d, yest)) return '昨天' + if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日` + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日` +} +``` + +vitest 单测(若仓库有 format 测试文件则追加,无则新建 `src/utils/__tests__/format.spec.ts`):今天 0 点边界/昨天/今年/跨年 4 例。 + +- [ ] **Step 6: 类型检查 + Commit** + +```bash +cd yunkes && npx vue-tsc --noEmit +git add -A yunkes/src && git commit -m "feat(staffchat): 路由+API client+类型+relativeChatTime(还原旧页WIP)" +``` + +--- + +### Task 9: `StaffChat.vue` 骨架 + `StaffChatSide.vue`(资料卡+5导航+折叠) + +**Files:** +- Create: `yunkes/src/views/StaffChat.vue` +- Create: `yunkes/src/components/staffchat/StaffChatSide.vue` + +- [ ] **Step 1: StaffChatSide.vue**(布局参照云客:头像行+昵称大字/真名·电话/微信号小字+竖排 5 导航+底部折叠按钮) + +```vue + + + +``` + +(`ownerIdFallback` 用 props 传 ownerId 兜底昵称空;样式沿用 ChatSidebar 的墨玉主题变量与手法,scoped。) + +- [ ] **Step 2: StaffChat.vue 骨架**(路由驱动 tab + lazy 组件 + 折叠态) + +```vue + + + +``` + +- [ ] **Step 3: 其余 5 个子组件先建**最简占位(``),让骨架可跑 +- [ ] **Step 4: `npm run dev` 手动访问 `/staff-chat/wxid_wo97u1u2x11w22`**——左栏资料卡真实数据、5 导航切换 URL 变化、折叠/展开可用 +- [ ] **Step 5: vue-tsc 通过 → Commit** `feat(staffchat): 页面骨架+左侧资料卡/5导航/折叠` + +--- + +### Task 10: `SessionPane.vue`(中列会话:双模式/已删除tab/置顶区) + +**Files:** +- Create: `yunkes/src/components/staffchat/SessionPane.vue` + +对齐云客:顶部「好友/群|聊天记录」下拉+搜索框;single 时 `聊天好友|已删除好友` tab;置顶区(filehelper+折叠条);会话卡=头像+昵称+今日N+relativeChatTime。 + +- [ ] **Step 1: 实现**(从 `ChatSidebar.vue` 复制改造——样式类直接沿用) + +```vue + +``` + +模板结构(对齐云客三块):`mode 下拉(el-select 两项)` + `搜索框(list 模式 placeholder='客户昵称,微信号' 前端过滤昵称/ID; search 模式 placeholder='请输入关键词搜索' 回车调 runSearch)` + `single 时 el-tabs 聊天好友|已删除好友` + `置顶区(pinnedOpen 控制展开/折叠条文案'折叠置顶聊天')` + `会话卡列表(头像+昵称+今日N 红badge+relativeChatTime(last_chat_time))`。active 高亮按 `activeId === (talker_id ?? room_id)`。 + +- [ ] **Step 2: 手动验证**:单聊显示置顶区(文件传输助手)+聊天好友列表;切「已删除好友」重查(is_del=1);群聊 tab 只显群;「聊天记录」模式搜索关键词出会话 +- [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 会话中列(双模式搜索+已删除好友tab+置顶区+相对时间)` + +--- + +### Task 11: `ChatMain.vue`(右列消息流:近14天窗/类型10类/msgid 定位/导出) + +**Files:** +- Create: `yunkes/src/components/staffchat/ChatMain.vue` + +从 `ChatTimeline.vue` 主区(消息加载分页/气泡/详情 drawer/导出)复制改造;差异点: +- **默认近 14 天窗**:`dateRange = ref<[Date, Date] | null>([今天-13天 00:00:00, 今天 23:59:59])`(云客默认;清空=全部时间) +- **类型下拉 10 类**(值=后端 `msg_type`):文本 text/图片 image/语音消息 voice/语音通话 voice_call/视频通话 video_call/文件 file/小程序名片 mini_program→(映射 link)/链接 link/红包 red_packet/转账 transfer——**取 Task 4 最终映射对齐命名**,查询带 `msg_type` +- **msgid 定位**:`route.query.msgid` 存在时,加载完成后在已加载消息中找 `msg_id` 匹配→`scrollIntoView`+高亮 class;未找到提示「该消息不在当前页,请加载更多」 +- **标题**:single=`与 {对方昵称} 的聊天记录`,group=`群聊记录 · {群名}`(昵称获取逻辑从 ChatTimeline 的 loadTalkerName 复制) +- 保留:加载更多/上一页(锚定滚动)、日期分隔条(MessageBubble 内已有)、导出按钮(`downloadBlob('/export/chat-html', ...)`)、消息详情 drawer(buildMessageSections) +- group 时查询 `room_id=talkerId`(ChatTimeline 的 isGroup 分支逻辑复制) + +- [ ] **Step 1: 实现**(代码主体=ChatTimeline.vue 178-335 行的 script 迁移+上述 4 点差异;模板同构) +- [ ] **Step 2: 手动验证**:默认窗生效(老对话被过滤,清空后可见);类型下拉过滤图片只剩图片;日期窗/关键词/导出复用正常;带 `?msgid=` 进入滚动定位 +- [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 消息右列(近14天默认窗+10类过滤+msgid定位+导出)` + +--- + +## Phase 3 其余视图 + +### Task 12: `MomentsPane.vue`(朋友圈 feed) + +**Files:** +- Create: `yunkes/src/components/staffchat/MomentsPane.vue` + +- [ ] **Step 1: 实现**:头部(`header.nickname`+avatar+alias)+ 帖卡流(`el-card` 列表:作者头像+名 → content 文本 → `el-image` 九宫格(`preview-src-list`)→ `formatTime(create_time)` → 点赞人行 `❤ name1; name2...`);滚动到底加载 `page+1`(IntersectionObserver 或 scroll 事件,`items.length < total` 时);空态「该员工暂无朋友圈数据,请先运行 moments 抓取」;图片空数组跳过九宫格 +- [ ] **Step 2: 手动验证**(若库里暂无 moments 数据,先跑一次 `--entity moments` 或用 seed 脚本插两条假数据目检渲染) +- [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 朋友圈feed(九宫格预览+点赞列表+滚动翻页)` + +### Task 13: `ContactsPane.vue`(通讯录:客户表+群表+批量操作) + +**Files:** +- Create: `yunkes/src/components/staffchat/ContactsPane.vue` + +- [ ] **Step 1: 实现** + +顶部:`微信客户|微信群` el-tabs + 搜索框(客户 tab placeholder 对齐云客「支持微信昵称、微信号、备注名、手机号搜索」→ 调 `/api/customers?q=` 检查现有参数名,若无 q 用 `keyword`——**查 customer.py 现有过滤参数后对齐**;群 tab placeholder「请输入群名称」→ `/api/groups` 的 room_name 过滤,无则前端过滤当前页)+ 按钮 `查询/导出(CSV复用 csvExport.ts 模式)/导出聊天记录(勾选后启用,串行 downloadBlob /export/chat-html)`。 + +客户表列(对齐云客 7+1):复选框(el-table selection)/客户微信(头像+昵称+friend_id 复用 StaffList 富单元格手法)/数据来源(固定「工作手机」)/客户备注名(friend_remark)/最近沟通时间(last_chat_time)/发送条数(send_num)/接收条数(receive_num)/操作(查看聊天记录→emit 跳 single+talkerId;导出HTML聊天记录→downloadBlob)。 + +群表列(对齐云客 8+1,Task 3 新列):复选框/群信息(room_name)/数据来源/群主信息(owner_name+owner_wxid)/群成员数量(member_count)/群成员好友数量(friend_count)/消息数量(message_count)/操作(查看聊天记录→跳 group+roomId;导出HTML)。 + +分页:`el-pagination` 总数=API total,10 条/页(对齐云客)。 + +- [ ] **Step 2: 手动验证**:两 tab 数据/搜索/分页/勾选批量导出启用态/操作列两个按钮行为 +- [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 通讯录(客户表+群表+批量勾选导出,列对齐云客真机)` + +### Task 14: `StatsPane.vue`(统计分析 7 tab) + +**Files:** +- Create: `yunkes/src/components/staffchat/StatsPane.vue` + +- [ ] **Step 1: 实现** + +- 顶部 7 个 tab(`el-tabs`,label 带 `(N)` 计数——进入时 `getStaffStatsCounts` 拉一次,日期窗变更重拉) +- 日期窗默认近 7 天(`[今天-6天 00:00, 今天 23:59:59]`),`clearable` +- 明细表**列配置按 kind 驱动**(渲染通用 `el-table :columns` 思路): + +```ts +const COLS: Record = { + new_customers: [ + { prop: 'nickname', label: '客户微信', kind: 'link' }, { prop: 'remark', label: '客户备注' }, + { prop: 'staff', label: '员工微信' }, { prop: 'time', label: '时间' }, + ], + new_groups: [{ prop: 'room_name', label: '微信群信息', kind: 'link' }, { prop: 'staff', label: '员工微信' }, { prop: 'time', label: '时间' }], + red_transfer: [ + { prop: 'op_type', label: '操作类型' }, { prop: 'nickname', label: '客户微信', kind: 'link' }, + { prop: 'staff', label: '员工微信' }, { prop: 'amount', label: '金额' }, { prop: 'time', label: '发生时间' }, + { prop: '_action', label: '操作' }, + ], + del_customers: [{ prop: 'nickname', label: '客户微信', kind: 'link' }, { prop: 'staff', label: '员工微信' }, { prop: 'time', label: '发生时间' }], + blocked_customers: /* 同 del_customers */, + voice_calls: /* CallList 列:通话人/通话类型/来源/呼入呼出/接通状态/通话录音/通话时间/操作——从 CallList.vue 抄列模板 */, + sensitive: [], +} +``` + +- `link` 列:客户→跳 single+talkerId;群→跳 group+roomId;red_transfer `_action`→跳 single/group + `?msgid=` 定位 +- 敏感词 tab:空态卡「词库未配置,待 C1 敏感词功能接入」 +- 金额列 `amount ?? '—'`(云客本条也显 `-`) +- time 格式化 formatTime + +- [ ] **Step 2: 手动验证**:7 tab 计数徽标(对本地库算的数抽查 1-2 个 SQL 核对);red_transfer 行点查看跳聊天并定位;日期窗变更重查 +- [ ] **Step 3: vue-tsc → Commit** `feat(staffchat): 统计分析7tab(分型明细列+msgid定位+近7天默认窗)` + +--- + +## Phase 4 入口 + 走查 + +### Task 15: 入口改造 + Playwright 对照走查 + +**Files:** +- Modify: `yunkes/src/views/StaffList.vue`(微信行 viewChat→staff-chat;modal openCustomerChat→staff-chat) +- Modify: `yunkes/src/views/CustomerList.vue`(微信行查看聊天→staff-chat) + +- [ ] **Step 1: StaffList.vue 两处跳转改** + +```ts +async function viewChat(row: Account) { + // 微信走五视图聊天窗口(2026-08-25 对齐云客);WA 保持旧双栏 + if (row.channel === 'wechat') { + await router.push({ name: 'staff-chat', params: { ownerId: row.account_id, tab: 'single' } }) + } else { + await router.push({ name: 'chat', params: { channel: row.channel, ownerId: row.account_id } }) + } +} +// openCustomerChat 同理 wechat 分支 → { name: 'staff-chat', params: { ownerId, tab: 'single', talkerId } } + query 保留 start/end/msgid +``` + +- [ ] **Step 2: CustomerList.vue** 微信 tab「查看聊天记录」同样 wechat 分支切 staff-chat(找到现有跳转处,约 line 301 操作列) +- [ ] **Step 3: 全链路手动/Playwright 走查**(对照云客真机,逐项勾): + - 员工列表昵称→新页;资料卡四要素;5 导航切换 URL/内容 + - 单聊:置顶区/已删除 tab/双模式搜索/默认窗/类型过滤/导出/msgid 定位 + - 群聊:无已删除 tab,点群右侧群消息带发送者 + - 朋友圈:feed 渲染/九宫格预览/翻页 + - 通讯录:两 tab 列/搜索/分页/勾选批量/操作列 + - 统计:7 tab 计数/明细列型/链接跳转 + - WA 侧回归:WA 员工→旧 ChatTimeline 正常(未受影响) +- [ ] **Step 4: 后端全量测试 + 前端构建** + +```bash +cd yunke-server && .venv/Scripts/python.exe -m pytest -q # 全绿 +cd ../yunkes && npx vue-tsc --noEmit && npm run build # 通过 +``` + +- [ ] **Step 5: Commit** `feat(staffchat): 入口改造(StaffList/CustomerList微信行)+全链路走查` + +--- + +## 附:判别记录(Task 4 Step 1 填写) + +| type_raw | 样本摘要 | 判定 type | +|---|---|---| +| 21 | | | +| 15 | | | +| 9 | | | +| 10 | | | +| 4 | | | +| 18 | | | +| 8 | | | +| 6 | | | +| 22 | | | +| 13 | | | +| 14 | | | +| 5 | | | +| 11 | | | +| 20 | | | +| 10000 | | | +| 7 | | |