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

57 KiB
Raw Blame History

云客员工聊天窗口对齐(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_client seed 一个带 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——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)

    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_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: 写失败测试(核心口径各一条 + 转换坑一条)

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 无样本行(查空属数据现实)