2026-08-25 12:31:53 +08:00
# 云客员工聊天窗口对齐(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` (沿用脚本内既有步骤格式)
2026-08-25 12:35:41 +08:00
- [ ] **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 表)
2026-08-25 12:31:53 +08:00
- [ ] **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`
2026-08-25 12:35:41 +08:00
- [ ] **Step 6: schemas.py `GroupOut` 加 6 字段** ——groups.py 用的是 `GroupOut.model_validate(row, from_attributes=True)` ( groups.py:75 模式),**只需在 schema 加字段即可自动输出**,路由无需改
2026-08-25 12:31:53 +08:00
- [ ] **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: 实现**
2026-08-25 12:35:41 +08:00
顶部:`微信客户|微信群` 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 参数则前端过滤当前页。
2026-08-25 12:31:53 +08:00
客户表列(对齐云客 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
```
2026-08-25 12:35:41 +08:00
- [ ] **Step 2: CustomerList.vue** 微信 tab「查看聊天记录」同样 wechat 分支切 staff-chat(现有跳转在 ~line 252-267 的 viewChat 及其 drawer 包装处,以「查看聊天记录」grep 定位为准)
2026-08-25 12:31:53 +08:00
- [ ] **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微信行)+全链路走查`
---
2026-08-25 15:32:07 +08:00
## 附:判别记录(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 无样本行(查空属数据现实)