Files
yunke/docs/superpowers/plans/2026-08-25-yunke-staff-chat-window.md
T

1024 lines
57 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.
# 云客员工聊天窗口对齐(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/<file> -v`(Windows Git Bash)
- 前端测试/构建:`cd yunkes && npm run test 2>/dev/null || npx vitest run <file>`;类型检查 `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/<rev>_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 ... -> <rev>, 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 <wxid> +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 7b: 调度接入 + 图片本地化**(spec §4.2 两项)
- 调度:`app/sync/scheduler.py` 的 4h job 是子进程跑 `--entity messages`——在该 job 的子进程序列**追加一次** `--entity moments --max-pages 3`(浅扫第一页有新才继续,朋友圈更新频率低);镜像现有 `_page_subprocess` 调用模式,勿新增 job
- 图片:`moments_post.image_urls` 是 OSS 直链(`wx.oss.yunkecn.com/friendstr/...`)。在 `scrape_media` 的媒体发现段加一个 moments 源(SELECT 未处理的 moments 行,image_urls 灌入 media 表,复用 message 图片的 URL 型下载路径);若 media 表按 `(kind, source_id)` 去重,kind 用 `moments_image`。执行时先读 `scrape_via_page.py:1838` 的 scrape_media 结构再动笔
- [ ] **Step 7c: 上面两处各加最小测试**(scheduler 若为纯配置列表则断言 moments 在序列;media 源加一行 fixtures 用例断言 URL 入 media 表)
- [ ] **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/<rev>_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 用的是 `GroupOut.model_validate(row, from_attributes=True)`(groups.py:75 模式),**只需在 schema 加字段即可自动输出**,路由无需改
- [ ] **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": "<msg><appmsg><type>2001</type>"}) == "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="<msg><appmsg><type>2001</type>"))
# 语音通话: 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 `<feedesc>`/`<totalamount>` 正则提取,失败 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<string, unknown>[] }
```
- [ ] **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<MomentsList> {
return getJson<MomentsList>('/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<StaffStatsCounts> {
return getJson<StaffStatsCounts>('/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<StaffStatsDetail> {
return getJson<StaffStatsDetail>('/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
<script setup lang="ts">
import { computed } from 'vue'
import type { Account } from '@/api/types'
const props = defineProps<{ profile: Account | null; tab: string }>()
const emit = defineEmits<{ select: [tab: string]; collapse: [] }>()
const NAVS = [
{ key: 'single', label: '单聊' }, { key: 'group', label: '群聊' }, { key: 'moments', label: '朋友圈' },
{ key: 'contacts', label: '通讯录' }, { key: 'stats', label: '统计分析' },
] as const
const phone = computed(() => props.profile?.phone || '')
</script>
<template>
<div class="sc-side">
<!-- 员工资料卡: 头像+昵称 / 真名·电话 / 微信号 (对齐云客) -->
<div class="sc-side__card">
<el-avatar :size="44" :src="profile?.avatar ?? undefined">{{ profile?.nickname?.slice(0, 1) }}</el-avatar>
<div class="sc-side__who">
<div class="sc-side__nick">{{ profile?.nickname || ownerIdFallback }}</div>
<div class="sc-side__meta">{{ profile?.user_name }} {{ phone }}</div>
<div class="sc-side__meta">{{ profile?.account_id }}</div>
</div>
</div>
<nav class="sc-side__nav">
<button v-for="n in NAVS" :key="n.key" class="sc-side__navbtn" :class="{ 'is-active': tab === n.key }" @click="emit('select', n.key)">{{ n.label }}</button>
</nav>
<button class="sc-side__collapse" @click="emit('collapse')">收起侧边栏</button>
</div>
</template>
```
(`ownerIdFallback` 用 props 传 ownerId 兜底昵称空;样式沿用 ChatSidebar 的墨玉主题变量与手法,scoped。)
- [ ] **Step 2: StaffChat.vue 骨架**(路由驱动 tab + lazy 组件 + 折叠态)
```vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAccounts } from '@/api'
import type { Account } from '@/api/types'
import StaffChatSide from '@/components/staffchat/StaffChatSide.vue'
import SessionPane from '@/components/staffchat/SessionPane.vue'
import ChatMain from '@/components/staffchat/ChatMain.vue'
import MomentsPane from '@/components/staffchat/MomentsPane.vue'
import ContactsPane from '@/components/staffchat/ContactsPane.vue'
import StatsPane from '@/components/staffchat/StatsPane.vue'
const props = defineProps<{ ownerId: string; tab?: string; talkerId?: string }>()
const route = useRoute(); const router = useRouter()
const VALID = ['single', 'group', 'moments', 'contacts', 'stats']
const tab = computed(() => (props.tab && VALID.includes(props.tab) ? props.tab : 'single'))
// 非法 tab 重定向
watch(() => props.tab, (t) => { if (t && !VALID.includes(t)) router.replace({ name: 'staff-chat', params: { ownerId: props.ownerId, tab: 'single' } }) }, { immediate: true })
const profile = ref<Account | null>(null)
const collapsed = ref(false)
async function loadProfile() {
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
profile.value = res.items.find(a => a.account_id === props.ownerId) ?? null
}
watch(() => props.ownerId, loadProfile, { immediate: true })
/** 导航切换: 保留 ownerId, 清 talkerId(跨视图无意义); single↔group 保留 id 语义转换不做(云客也清) */
function onSelectTab(t: string) { router.push({ name: 'staff-chat', params: { ownerId: props.ownerId, tab: t } }) }
/** 会话点击: single→talkerId, group→roomId 塞进 talkerId 段 */
function onSelectSession(s: { type: 'friend' | 'group'; talker_id?: string | null; room_id?: string | null }) {
router.push({ name: 'staff-chat', params: { ownerId: props.ownerId, tab: s.type === 'group' ? 'group' : 'single', talkerId: s.talker_id ?? s.room_id ?? '' }, query: route.query.msgid ? { msgid: route.query.msgid } : {} })
}
const chatMode = computed(() => tab.value === 'single' || tab.value === 'group')
</script>
<template>
<div class="sc-root" :class="{ 'is-collapsed': collapsed }">
<StaffChatSide v-if="!collapsed" :profile="profile" :owner-id-fallback="ownerId" :tab="tab" @select="onSelectTab" @collapse="collapsed = true" />
<button v-else class="sc-root__expand" @click="collapsed = false">展开</button>
<SessionPane v-if="chatMode" :channel="'wechat'" :owner-id="ownerId" :kind="tab" :active-id="talkerId" @select="onSelectSession" />
<ChatMain v-if="chatMode" :owner-id="ownerId" :kind="tab" :talker-id="talkerId" />
<MomentsPane v-else-if="tab === 'moments'" :owner-id="ownerId" />
<ContactsPane v-else-if="tab === 'contacts'" :owner-id="ownerId" />
<StatsPane v-else-if="tab === 'stats'" :owner-id="ownerId" />
</div>
</template>
```
- [ ] **Step 3: 其余 5 个子组件先建**最简占位(`<template><div class="yk-empty">实现于 Task N</div></template>`),让骨架可跑
- [ ] **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
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getMessages, getSessions } from '@/api'
import type { ChatSession } from '@/api/types'
import { relativeChatTime } from '@/utils/format'
const props = defineProps<{ channel: string; ownerId: string; kind: 'single' | 'group'; activeId?: string }>()
const emit = defineEmits<{ select: [ChatSession] }>()
const mode = ref<'list' | 'search'>('list') // 好友/群 | 聊天记录
const friendTab = ref<'live' | 'deleted'>('live') // 聊天好友 | 已删除好友 (仅 single)
const keyword = ref('')
const sessions = ref<ChatSession[]>([])
const loading = ref(false)
const pinnedOpen = ref(true)
async function load() {
loading.value = true
try {
const res = await getSessions({ owner_account_id: props.ownerId, channel: props.channel as never,
...(props.kind === 'single' ? { is_del: friendTab.value === 'deleted' ? 1 : 0 } : {}) })
sessions.value = res.sessions
} finally { loading.value = false }
}
watch(() => [props.ownerId, props.kind, friendTab.value] as const, load, { immediate: true })
/** group 视图复用同一列表但只显群(sessions 已含 type);kind=group 时服务端 type 参数后端 sessions 不分——前端过滤即可 */
const visible = computed(() => {
let list = mode.value === 'list' ? sessions.value : searchResults.value
if (props.kind === 'group') list = list.filter(s => s.type === 'group')
else list = list.filter(s => s.type === 'friend')
if (props.kind !== 'single' || friendTab.value !== 'deleted') {
// 聊天好友视图: 置顶单独一区,不重复出现在列表
return list.filter(s => !s.pinned)
}
return list
})
const pinned = computed(() => (props.kind === 'single' ? sessions.value.filter(s => s.pinned) : []))
/** 聊天记录模式: 关键词全量搜消息(q 参数),结果按会话聚合去重显示 */
const searchResults = ref<ChatSession[]>([])
async function runSearch() {
const k = keyword.value.trim()
if (mode.value !== 'search' || !k) { searchResults.value = []; return }
const res = await getMessages({ channel: props.channel as never, owner_account_id: props.ownerId, q: k, page: 1, page_size: 200 } as never)
const seen = new Map<string, ChatSession>()
for (const m of res.items) {
const key = m.room_id ?? m.talker_id ?? ''
if (!seen.has(key)) seen.set(key, { type: m.room_id ? 'group' : 'friend', talker_id: m.talker_id, room_id: m.room_id, name: m.talker_id, today_count: 0, last_chat_time: m.timestamp })
}
searchResults.value = [...seen.values()]
}
</script>
```
模板结构(对齐云客三块):`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 + 搜索框 + 按钮 `查询/导出(CSV复用 csvExport.ts 模式)/导出聊天记录(勾选后启用,串行 downloadBlob /export/chat-html)`。
**搜索实现前置**:`/api/customers`(customer.py)现状**没有**任何文本搜索参数(只有 channel/department_id/owner_account_id/friend_id/user_id/add_time_since/last_chat_since)。本任务先给 customer.py 加 `q: str | None`(`sa.or_(friend_nickname.like, friend_remark.like, friend_id.like, phone.like)`,几行+1 测试),客户 tab 搜索走它(placeholder 对齐云客「支持微信昵称、微信号、备注名、手机号搜索」);群 tab placeholder「请输入群名称」→ `/api/groups` 无 room_name 参数则前端过滤当前页。
客户表列(对齐云客 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<string, { prop: string; label: string; kind?: 'link' | 'amount' }[]> = {
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 252-267 的 viewChat 及其 drawer 包装处,以「查看聊天记录」grep 定位为准)
- [ ] **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 spike 结论,2026-08-25 实测 smoke.db,每类 ≥3 样本)
**核心发现:云客 API 双编码**——`raw_json.messageType`=经典微信协议码(1/3/34/43/47/42/48/49/50/10000+合成大数),`raw_json.type`=云客自编码;DB `type_raw` 列两者混合。归一化采用 **messageType 优先 → 49/appmsg 行级拆分 → 云客码兜底** 三层。
| type_raw(云客码) | messageType | 行数 | 样本摘要 | 判定 type | 置信度 |
|---|---|---|---|---|---|
| 1 | '1' | 473,922 | 普通会话文本 | text | 强 |
| 2 | '3' | 50,154 | md5+OSS fileTh URL | image | 强 |
| 3 | **'34'** | **8,754** | **file=语音hex键、text=时长数字** | **voice(旧 3→image_legacy 系误标,已回填修正)** | 强 |
| 3 | '3' | 201 | md5+OSS 图片 | image | 强 |
| 4 | '43' | 4,835 | picType/md5 视频 | video | 强 |
| 5 | '436207665' | 625 | file=wx.gtimg.com/hongbao 封面 | red_packet | 强 |
| 6 | '50' | 2,077 | VOIP+duration 无 text | voice_call | 中(6/7 同为 mt=50 无区分字段,按频率先验;最坏 77 行互换) |
| 7 | '50' | 77 | 同上 | video_call | 中(同上) |
| 8 | '47' | 4,224 | tc.qq.com 表情 CDN | emoji | 强 |
| 9 | '49' | 9,075 | xlsx/pdf 文件名+OSS file | file | 强 |
| 10 | '49' | 6,868 | 92% 带封面图(车源模板文本) | link | 强 |
| 11 | '419430449' | 386 | text="发送转账:¥100.00" | transfer | 强 |
| 13 | '42' | 950 | text=昵称,file=头像 | namecard | 强 |
| 14 | '48' | 647 | text=地址,file="lat,lng" | location | 强 |
| 15 / 10000 | '10000'/合成码 | 18,489 | "你已添加了…打招呼消息" | system | 强 |
| 18 | '49' | 4,792 | 99.8% 公众号封面 mmbiz.qpic.cn | link | 强 |
| 20 | '754974769' | 129 | 视频号封面+话题#标签 | link | 中(视频号分享,非原生视频) |
| 21 非XML | '822083633' | 30,165 | 全部带 referMsg(引用回复) | text | 强 |
| 21 XML | '49' | 1,701 | `<type>19</type>` 聊天记录 | merged_forward | 强 |
| 22 | '922746929' | 1,877 | 零 text,带 patMsgRecordNum/patMsgs(拍一拍) | unknown(保守——拍一拍无规范类型,system 会渲染空) | 证据不足留 unknown |
| 19 | '-1879048186' | 5 | 空文本无字段 | unknown | 证据不足 |
| 49 旧行 | '49' | 202 | raw_json.type 子码 10/21/18/9 | link(143)/file(52)/merged_forward(7) | 强 |
- paymsgid 实测数据全空(红包/转账真实判据=hongbao 封面 URL 与"发送转账"文本);paymsgid 非空且无 XML type → transfer(转账语义,比 plan 草稿更准)
- 回填结果(2026-08-25 实跑):scanned 620,279 / updated 94,268 / unknown 87,198→1,885 / image_legacy 8,955→0;终态 text 504,174 · system 18,489 · image 50,355 · link 11,932 · file 9,075 · voice 8,815 · video 4,835 · emoji 4,242 · voice_call 2,077 · merged_forward 1,704 · namecard 953 · location 651 · red_packet 625 · transfer 390 · video_call 77
- **范围外遗留**:`app/scraper/parsers.py normalize_wechat_type`(orchestrator 旧路径)仍写旧标签——orchestrator 运行后需重跑回填自愈;WhatsApp 渠道 unknown ~8,800 行是另一套编码,建议独立任务
- **前端 Task 11 注意**:时间线会出现下拉之外的 type 标签(system/emoji/namecard/location/merged_forward/unknown)——气泡渲染需兜底;mini_program 无样本行(查空属数据现实)