Co-Authored-By: Claude <noreply@anthropic.com>
57 KiB
云客员工聊天窗口对齐(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追加)
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 做)
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的写法)
.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模式)
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
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 片段)
"""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
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
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_clientseed 一个带 raw_json 的 ChatGroup)
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 全部)
# 通讯录·微信群 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
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 抽样,产出写进本文件底部"判别记录")
.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 红包/转账拆分)
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——SELECTtype='unknown' OR type IS NULL分批(5000/批),逐行调新_normalize_msg_typeUPDATE;幂等;打印各 type 计数。注意 SQLite 参数上限,复用分批模式。 -
Step 5: messages API 加参数(
messages.py,msg_type: str | None+q: str | None)
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
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(追加):
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(追加):
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
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: 写失败测试
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: 确认失败 → 实现
"""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
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_idjoin 后查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: 写失败测试(核心口径各一条 + 转换坑一条)
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 + 分节聚合函数)
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
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
cd /d/refactor/yunke && git checkout -- yunkes/src/views/ChatTimeline.vue yunkes/src/api/index.ts
git status # 确认这两文件回到干净
- Step 2: 路由(router/index.ts,chat 路由块后加)
{
// 员工聊天窗口(对齐云客 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 追加
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 模式与现有一致)
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(云客相对时间格式)
/** 云客聊天窗口相对时间: 今天→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
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 导航+底部折叠按钮)
<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 组件 + 折叠态)
<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复制改造——样式类直接沿用)
<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思路):
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 两处跳转改
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: 后端全量测试 + 前端构建
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 无样本行(查空属数据现实)