Files
yunke/docs/superpowers/plans/2026-08-06-yunke-missing-operations.md
T
浅念 a66c3be00a chore: final Minor polish — export filename/test断言, downloadBlob 类型/清理, plan docs
address review Minors: drop redundant filename replace, assert 暂无消息,
tighten downloadBlob return type + try/finally, fix plan group time-picker wording.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 18:06:17 +08:00

34 KiB
Raw Blame History

云客沟通板块漏操作补丁 Implementation Plan(重写)

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: 补全云客「沟通」板块已实现页面里被漏掉/判错的表格内操作——微信员工 5 个统计数字→明细 modal、主badge→聊天、员工姓名→详情(纠正误判的手机号)、modal 内客户微信→聊天记录、微信客户/微信群的「导出HTML聊天记录」(含时间选择)。

Architecture: 前端为主:StaffList.vue 微信 tab 把 4 列纯文本数字改成可点→复用现成的 YkStaffDetailModal(数据源调现有 /api/customers);YkStaffDetailModal 的 Col 加可选 onClick 支持二级跳转;CustomerList.vue 微信 tab 操作列加导出按钮 + 时间选择 dialog;GroupList.vue 微信群 tab 操作列加导出按钮(群无时间选择)。后端仅新增 1 个 GET /api/export/chat-html 端点(DM/群通用 + 时间范围),TDD。

Tech Stack: FastAPI + SQLAlchemy 2.0 async(后端);Vue3 + TS + Element Plus + Pinia(前端);后端测试 pytest + make_client fixture(in-memory sqlite)。

Spec/事实依据: docs/superpowers/specs/2026-08-06-yunke-comm-operations-audit.md(2026-08-06 真机核对表,cursor:pointer 全覆盖 + 零采信)。 参考既有: Plan1 StaffList.vue(WhatsApp tab 已用 YkStaffDetailModal 的 openAddedModal/openTalkedModal 是现成模板)。


范围

本 plan 覆盖(已实现页面的表格内漏操作):

  • 微信员工:沟通数 3 数字(沟通人数/有效沟通数/跟进客户数)→ modal;及时回复 2 数字(员工最后回复数/未回复客户数)→ modal;「主」badge→聊天;员工姓名→drawer(纠正:原 missing-operations 写的「手机号」是误判,手机号不可点,可点的是员工姓名);modal 内客户微信→聊天记录。
  • 微信客户:操作列加「导出HTML聊天记录」+ 时间选择 dialog。
  • 微信群:操作列加「导出HTML聊天记录」。
  • 后端:GET /api/export/chat-html(DM 用 talker_id / 群用 room_id + start/end 时间范围)。

不在本 plan 范围:

  • 语音通话列表 CallList(微信 voice-call-list + WA wavoicecalllist + 长语音 modal)—— 独立 Plan 4,前置抓取修复。
  • 工具栏批量操作(批量语音下载/全部导出/高级筛选)—— 随 CallList。
  • WA 侧(员工/客户/群)—— Plan1/2/3 已对齐,本轮真机复核无新漏操作。
  • 精确 KPI 度量还原(见下「数据源说明」)。

⚠️ 数据源说明(重要设计简化,执行前已知)

云客微信员工 5 个数字 modal 各有独立 KPI 语义(沟通人数/有效沟通数/跟进客户数/员工最后回复数/未回复客户数 是不同客户集合)。yunkes 的 Customer 表无「最后回复方向」汇总字段,要精确区分(尤其「员工最后回复数」vs「未回复客户数」需逐客户查最后一条 Message.is_send)需新增 message 聚合端点,超出「补丁」定位。

本 plan 决策:5 个数字 modal 的 fetcher 统一调 /api/customers?channel=wechat&owner_account_id={员工账号}(该员工的客户列表),modal 列结构按云客对齐(有效沟通数 modal 额外显示 send_num/receive_num 列,其余 4 个用基础列)。即交互完整(点任意数字→弹对应标题/列的 modal),度量精确区分(已回复 vs 未回复)作为已知简化,留待后续 message 聚合端点独立增强。此简化须在收尾验收时确认产品可接受。


运行命令(验证用)

  • 后端:cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000 --log-level warning
  • 后端测试:cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
  • 前端:cd yunkes && npm run dev
  • 前端类型检查:cd yunkes && npx vue-tsc -b

File Map

后端(yunke-server/):

  • Create app/api/routers/export.py — GET /api/export/chat-html(拉 messages + 渲染 HTML + attachment 下载)
  • Modify app/api/main.py — 注册 export router(参照其他 router 注册,约 L38-48 import + L59-67 include_router)
  • Create tests/test_api_export.py — 导出端点 TDD(make_client 范式)

前端(yunkes/src/):

  • Modify components/YkStaffDetailModal.vue — Col 接口加 onClick?: (row) => void,渲染时该列包 el-button link
  • Modify views/StaffList.vue — 微信 tab:4 列数字→可点 modal、主badge→viewChat、员工姓名→openDrawer、modal 客户微信列 onClick
  • Modify views/CustomerList.vue — 微信 tab 操作列(L301-307)加导出按钮 + 时间选择 dialog + exportChatHtml
  • Modify views/GroupList.vue — 微信群 tab 操作列(L270-276)加导出按钮 + exportChatHtml
  • Modify api/client.ts — 加 downloadBlob(url, params) helper(http.get + responseType:'blob')

Task 1: 后端导出 HTML 端点(TDD)

事实:Message 有 is_send(Boolean 方向)/timestamp(ms)/talker_id/room_id/text/channel/owner_account_id,无 direction。get_session 无参(app.api.deps)。测试用 make_client(无 async_engine fixture),范式见 tests/test_api_messages.py 的 _seed。

Files:

  • Create: yunke-server/tests/test_api_export.py

  • Create: yunke-server/app/api/routers/export.py

  • Modify: yunke-server/app/api/main.py

  • Step 1: 写失败测试(tests/test_api_export.py):

from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Message


async def _seed(session: AsyncSession) -> None:
    session.add_all([
        Message(msg_id="dm1", channel="wechat", owner_account_id="wx_owner",
                talker_id="wx_friend", room_id=None, text="你好",
                timestamp=1000, is_send=False, type="text", type_raw=1),
        Message(msg_id="dm2", channel="wechat", owner_account_id="wx_owner",
                talker_id="wx_friend", room_id=None, text="在的",
                timestamp=2000, is_send=True, type="text", type_raw=1),
        Message(msg_id="g1", channel="wechat", owner_account_id="wx_owner",
                talker_id="room_topic", room_id="g1@chatroom", text="群消息",
                timestamp=3000, is_send=True, type="text", type_raw=1),
        Message(msg_id="g2", channel="wechat", owner_account_id="wx_owner",
                talker_id="room_topic", room_id="g1@chatroom", text="范围外",
                timestamp=9999999, is_send=True, type="text", type_raw=1),
    ])
    await session.commit()


async def test_export_dm_returns_html(make_client):
    client = await make_client(_seed)
    r = await client.get("/api/export/chat-html", params={
        "channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "wx_friend"})
    assert r.status_code == 200
    assert r.headers["content-type"].startswith("text/html")
    assert "attachment" in r.headers["content-disposition"]
    body = r.text
    assert "你好" in body and "在的" in body
    assert "wx_owner" in body


async def test_export_group_with_time_range(make_client):
    client = await make_client(_seed)
    r = await client.get("/api/export/chat-html", params={
        "channel": "wechat", "owner_account_id": "wx_owner", "room_id": "g1@chatroom",
        "start": 0, "end": 5000})
    assert r.status_code == 200
    assert "群消息" in r.text
    assert "范围外" not in r.text  # end=5000 过滤掉 timestamp=9999999


async def test_export_empty(make_client):
    client = await make_client(_seed)
    r = await client.get("/api/export/chat-html", params={
        "channel": "wechat", "owner_account_id": "wx_owner", "talker_id": "none"})
    assert r.status_code == 200
    assert r.headers["content-type"].startswith("text/html")
  • Step 2: 跑测试确认 FAIL
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_export.py -x -q

预期:FAIL(404 路由不存在)。

  • Step 3: 实现 export router(app/api/routers/export.py):
"""GET /api/export/chat-html — 导出一段 1:1 / 群聊天为独立 HTML 文件。

对齐云客微信客户列表「导出HTML聊天记录」:按 (channel, owner, talker_id|room_id)
拉消息(可选 start/end 时间范围),渲染自包含 HTML 聊天页(is_send 决定左右气泡),
作为 attachment 返回下载。
"""
from __future__ import annotations

from datetime import datetime, timezone
from html import escape
from typing import Annotated

import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_session
from app.db.models import Message

router = APIRouter(prefix="/api/export", tags=["export"])

SessionDep = Annotated[AsyncSession, Depends(get_session)]


def _ts_text(ts: int | None) -> str:
    if not ts:
        return ""
    try:
        return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S")
    except (ValueError, OSError, OverflowError):
        return str(ts)


def _render_chat_html(rows: list[Message], channel: str, owner: str, peer: str) -> str:
    bubbles = []
    for m in rows:
        side = "right" if m.is_send else "left"
        ts = escape(_ts_text(getattr(m, "timestamp", None)))
        text = escape(getattr(m, "text", "") or "")
        bubbles.append(
            f'<div class="bubble {side}"><div class="t">{ts}</div><div class="b">{text}</div></div>'
        )
    title = f"聊天记录 · {escape(channel)} · {escape(owner)} / {escape(peer)}"
    return f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
<title>{title}</title><style>
body{{font-family:-apple-system,Segoe UI,sans-serif;background:#f5f5f5;margin:0;padding:16px}}
h1{{font-size:16px;margin:0 0 12px}}
.bubble{{max-width:70%;margin:6px 0;padding:8px 12px;border-radius:8px;clear:both}}
.bubble.right{{float:right;background:#95ec69;text-align:right}}
.bubble.left{{float:left;background:#fff}}
.bubble .t{{font-size:11px;color:#999;margin-bottom:2px}}
.bubble .b{{font-size:14px;white-space:pre-wrap;word-break:break-word}}
</style></head><body><h1>{title}</h1>
{''.join(bubbles) or '<p>暂无消息</p>'}
</body></html>"""


@router.get("/chat-html")
async def export_chat_html(
    session: SessionDep,
    channel: Annotated[str, Query()],
    owner_account_id: Annotated[str, Query()],
    talker_id: Annotated[str | None, Query()] = None,
    room_id: Annotated[str | None, Query()] = None,
    start: Annotated[int | None, Query(ge=0)] = None,
    end: Annotated[int | None, Query(ge=0)] = None,
) -> Response:
    q = (
        sa.select(Message)
        .where(Message.channel == channel)
        .where(Message.owner_account_id == owner_account_id)
    )
    peer = ""
    if room_id:
        q = q.where(Message.room_id == room_id)
        peer = room_id
    elif talker_id:
        # DM:限定 room_id 为空,避免误捞群消息
        q = q.where(Message.talker_id == talker_id).where(
            (Message.room_id == None) | (Message.room_id == "")  # noqa: E711
        )
        peer = talker_id
    if start is not None:
        q = q.where(Message.timestamp >= start)
    if end is not None:
        q = q.where(Message.timestamp <= end)
    q = q.order_by(sa.nullslast(Message.timestamp.asc()), Message.msg_id.asc())
    rows = (await session.execute(q)).scalars().all()
    html = _render_chat_html(list(rows), channel, owner_account_id, peer)
    safe = peer.replace("@", "_").replace("/", "_") or "all"
    filename = f"chat-{channel}-{owner_account_id}-{safe}.html".replace("@", "_").replace("/", "_")
    return Response(
        content=html,
        media_type="text/html; charset=utf-8",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )
  • Step 4: 注册 router(app/api/main.py,参照现有 import 块 L38-48 + include_router 块 L59-67 加两行):
from app.api.routers import export          # 加入 import 列表
app.include_router(export.router)           # 加入 include_router 列表
  • Step 5: 跑测试确认 PASS
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_export.py -x -q
  • Step 6: 全后端回归
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q

预期:全 PASS(原 ≥330 + 新增 3)。

  • Step 7: Commit
git add yunke-server/app/api/routers/export.py yunke-server/app/api/main.py yunke-server/tests/test_api_export.py
git commit -m "$(cat <<'EOF'
feat(api): GET /api/export/chat-html — 导出 1:1/群聊天为 HTML

支持 DM(talker_id) 与 群(room_id),可选 start/end 时间范围;
is_send 决定气泡左右。对齐云客微信客户/群列表「导出HTML聊天记录」。

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 2: 前端 downloadBlob helper

事实:api/client.ts 的 http(baseURL /api)响应拦截器返回 response.data;对 responseType:'blob' 也会原样透传 blob。无现成下载 helper。鉴权走同域 cookie,axios 自动带。

Files: Modify yunkes/src/api/client.ts

  • Step 1: 加 downloadBlob(client.ts 末尾,getJson 之后):
/** 以 blob 下载(用于 HTML 导出等二进制/文本附件下载)。返回建议文件名(来自 content-disposition,缺失则 undefined)。 */
export async function downloadBlob(
  url: string,
  params?: Record<string, unknown>,
): Promise<string | undefined> {
  const res = await http.get(url, { params, responseType: 'blob' })
  const blob = res as unknown as Blob
  // 从响应头取文件名(拦截器只解包 body,需用 raw 拦截;此处 res 已是 blob,header 丢失 → 用固定名)
  const filename = 'chat-export.html'
  const objUrl = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = objUrl
  a.download = filename
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  URL.revokeObjectURL(objUrl)
  return filename
}

说明:响应拦截器(client.ts L19-34)解包了 response.data,拿不到 Content-Disposition 头,故用固定文件名。可接受(云客导出文件名也非关键)。若需真实文件名,需另开 http.getRaw 走 AxiosResponse,本 plan 不做。

  • Step 2: 类型检查
cd yunkes && npx vue-tsc -b

预期:exit 0。

  • Step 3: Commit
git add yunkes/src/api/client.ts
git commit -m "$(cat <<'EOF'
feat(api): add downloadBlob helper for blob attachment download

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 3: YkStaffDetailModal Col 加 onClick 支持

事实:YkStaffDetailModal.vue 的 Col 接口(L9-26)= {prop,label,width?,minWidth?,align?,formatter?};列由 v-for="c in cols" 渲染(L57-91)。无 defineExpose,只能 v-model + props 驱动。目的:让 modal 内「客户微信」列可点→跳聊天记录(Task 7 用)。

Files: Modify yunkes/src/components/YkStaffDetailModal.vue

  • Step 1: Col 接口加 onClick(L9-17 的 interface Col,L18-26 是 defineProps):
interface Col {
  prop: string
  label: string
  width?: number | string
  minWidth?: number | string
  align?: 'left' | 'center' | 'right'
  formatter?: (row: any, column: any, cellValue: any, index: number) => string
  onClick?: (row: any) => void   // 新增:该列单元格可点(渲染为 link button)
}
  • Step 2: 模板列渲染支持 onClick(找到 v-for="c in cols" 的 <el-table-column>,其 <template #default="{ row }"> 内)。把单元格默认文本渲染改为:有 onClick 则包 el-button link:
<el-table-column
  v-for="c in cols"
  :key="c.prop"
  :prop="c.prop"
  :label="c.label"
  :width="c.width"
  :min-width="c.minWidth"
  :align="c.align"
  :formatter="c.formatter"
  show-overflow-tooltip
>
  <template v-if="c.onClick" #default="{ row }">
    <el-button link type="primary" size="small" @click="c.onClick!(row)">
      {{ c.formatter ? c.formatter(row, c, row[c.prop], 0) : row[c.prop] }}
    </el-button>
  </template>
</el-table-column>

没有 onClick 的列保留 :formatter 原生渲染(el-table 默认 slot 为空时走 prop+formatter)。implementer 先读 L57-91 现有模板对齐写法,保留无 onClick 列的原行为。

  • Step 3: 类型检查
cd yunkes && npx vue-tsc -b

预期:exit 0(现有 StaffList 用法未传 onClick,向后兼容)。

  • Step 4: Commit
git add yunkes/src/components/YkStaffDetailModal.vue
git commit -m "$(cat <<'EOF'
feat(modal): YkStaffDetailModal Col 支持可选 onClick(单元格可点)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 4: StaffList 微信员工「沟通数」3 数字 → modal

事实:微信 tab 沟通数列 StaffList.vue L489-504,3 个 <span class="yk-pair__v"> 纯文本:chatCount(L493 沟通人数) / contactCount(L497 有效沟通数) / followCount(L501 跟进客户数)。YkStaffDetailModal 已 import(L7)/已用(L673-678),状态 detailOpen/detailTitle/detailFetcher/detailCols(L226-232),baseCustomerCols(L237-242)。WhatsApp tab 的 openAddedModal(L244-255)/openTalkedModal(L257-272) 是现成模板。getCustomers 在 @/api(index.ts L46-60)。

Files: Modify yunkes/src/views/StaffList.vue

  • Step 1: 读现有模板对齐。先 Read StaffList.vue L226-272(detail 状态 + baseCustomerCols + openAddedModal/openTalkedModal)+ L673-678(modal 用法),理解 detailFetcher 怎么调 getCustomers、列怎么定义。新增的微信 modal 照此模式。

  • Step 2: 加 3 个微信沟通数 modal 开启函数(script 内,openTalkedModal 之后)。fetcher 统一调 getCustomers({channel:'wechat', owner_account_id: row.account_id, ...分页}):

/** 微信员工「沟通数」3 数字 → 明细 modal(数据源:该员工客户列表,度量精确区分见 plan 数据源说明)。 */
function openWechatCommModal(row: Account, title: string, withMsgCols: boolean): void {
  detailTitle.value = title
  detailCols.value = withMsgCols
    ? [
        { prop: 'friend_nickname', label: '客户微信', minWidth: 160 },
        { prop: 'send_num', label: '发送消息数', width: 110, align: 'center' },
        { prop: 'receive_num', label: '接收消息数', width: 110, align: 'center' },
      ]
    : [{ prop: 'friend_nickname', label: '客户微信', minWidth: 160 }]
  detailFetcher.value = async (page: number, page_size: number) =>
    getCustomers({ channel: 'wechat', owner_account_id: row.account_id, page, page_size })
  detailOpen.value = true
}

getCustomers 已在 L5 import。列对齐云客:沟通人数/跟进客户数 = 基础列;有效沟通数 = 基础列 + 发送/接收消息数。员工信息/员工微信列本 plan 省略(modal 已是某员工的明细,复用 modal 标题体现)。

  • Step 3: 沟通数列 3 个 span 改可点(L489-504)。把 3 个 <span class="yk-pair__v">{{ cellNum(row, '...') }}</span> 改为 el-button link:
<!-- 沟通人数 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '沟通人数(已去重)', false)">{{ cellNum(row, 'chatCount') }}</el-button>
<!-- 有效沟通数 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '有效沟通数(已去重)', true)">{{ cellNum(row, 'contactCount') }}</el-button>
<!-- 跟进客户数 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '跟进客户数', false)">{{ cellNum(row, 'followCount') }}</el-button>
  • Step 4: 类型检查
cd yunkes && npx vue-tsc -b

预期:exit 0。

  • Step 5: 浏览器验证(/staff 微信 tab)→ 点沟通人数/有效沟通数/跟进客户数 → 分别弹对应标题 modal,有效沟通数 modal 多两列。

  • Step 6: Commit

git add yunkes/src/views/StaffList.vue
git commit -m "$(cat <<'EOF'
feat(staff): 微信员工沟通数 3 数字 → 明细 modal(补 Plan1 误判漏做的操作)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 5: StaffList 微信员工「及时回复」2 数字 → modal

事实:及时回复列 L507-522,saleSendLastNum(L511 员工最后回复数) / saleSendLastNumRate(L515 回复率,不可点) / customerSendLastNum(L519 未回复客户数)。

Files: Modify yunkes/src/views/StaffList.vue

  • Step 1: 复用 Task4 的 openWechatCommModal(同模式,基础列)。及时回复 2 数字:
<!-- 员工最后回复数 L511 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '员工最后回复数', false)">{{ cellNum(row, 'saleSendLastNum') }}</el-button>
<!-- 回复率 L515 保持纯 span(不可点) -->
<span class="yk-pair__v">{{ cellText(row, 'saleSendLastNumRate') }}</span>
<!-- 未回复客户数 L519 -->
<el-button link type="primary" size="small" class="yk-pair__v" @click.stop="openWechatCommModal(row, '未回复客户数', false)">{{ cellNum(row, 'customerSendLastNum') }}</el-button>
  • Step 2: 类型检查 + 浏览器验证(点 2 数字弹 modal;回复率不可点)
cd yunkes && npx vue-tsc -b
  • Step 3: Commit
git add yunkes/src/views/StaffList.vue
git commit -m "$(cat <<'EOF'
feat(staff): 微信员工及时回复 2 数字 → 明细 modal(回复率不可点)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 6: StaffList 微信员工 主badge→聊天 + 员工姓名→drawer(纠正手机号误判)

事实:员工信息列 主/副badge 是 <el-tag>(L410-416,duoTagText),云客点「主」badge→聊天窗口;微信昵称 L417-419 已绑 viewChat;员工姓名 L422-424 纯 <span>,云客点→员工详情页;微信手机号 L426-428 纯 span 不可点。openDrawer(row) L334-337 已存在。viewChat(row) L196-219 跳 yunkes /chat(聊天记录,降级云客 wechat-window)。 纠正:原 missing-operations plan Task1「微信手机号→openDrawer」是误判——云客 button.ant-btn-link 包的是员工姓名(带 history 图标),手机号不可点。本 task 改员工姓名。

Files: Modify yunkes/src/views/StaffList.vue

  • Step 1: 主badge 可点→viewChat(L410-416 的 <el-tag>)加 @click.stop="viewChat(row)" 与 style="cursor:pointer":
<el-tag v-if="duoTagText(row)" :type="duoTagType(row)" effect="dark" size="small" class="yk-staffinfo__duo" style="cursor:pointer" @click.stop="viewChat(row)">{{ duoTagText(row) }}</el-tag>

云客点主badge→wechat-window 聊天窗口;yunkes 降级跳 /chat 聊天记录(同昵称)。implementer 读 L410-416 现有 el-tag 写法对齐属性。

  • Step 2: 员工姓名 可点→openDrawer(L422-424):
<el-button link type="primary" size="small" class="yk-staffinfo__v" @click.stop="openDrawer(row)">{{ cellText(row, 'userName') }}</el-button>

云客跳员工详情页 customer/userDetailNew;yunkes 无该页,降级开 drawer(全字段,复用现有 openDrawer)。手机号 L426-428 保持纯 span 不变(云客也不可点)。

  • Step 3: 类型检查 + 浏览器验证(点主badge/员工姓名→分别跳聊天/开 drawer;手机号不可点)
cd yunkes && npx vue-tsc -b
  • Step 4: Commit
git add yunkes/src/views/StaffList.vue
git commit -m "$(cat <<'EOF'
feat(staff): 微信员工 主badge→聊天 + 员工姓名→drawer(纠正手机号误判)

云客跳详情页的是员工姓名(button+history),非手机号;手机号不可点。

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 7: StaffList modal 内客户微信 → 聊天记录

事实:依赖 Task3 的 Col.onClick。云客 modal 内客户微信列→开 wechat/detail/message 聊天记录;yunkes 降级跳 /chat/{channel}/{owner}/{talker}(ChatTimeline)。modal 列数据是 Customer(friend_nickname/friend_id/owner_account_id/channel)。

Files: Modify yunkes/src/views/StaffList.vue

  • Step 1: 给 openWechatCommModal 的基础列加 onClick(修改 Task4 Step2 加的列定义,给「客户微信」列加 onClick):
import type { Customer } from '@/api/types'   // 若未 import 则加(参考 CustomerList 用法)

function openWechatCommModal(row: Account, title: string, withMsgCols: boolean): void {
  detailTitle.value = title
  const goChat = (r: Customer) => {
    if (!r.owner_account_id || !r.friend_id) return
    router.push({ name: 'chat', params: { channel: r.channel, ownerId: r.owner_account_id, talkerId: r.friend_id } })
  }
  detailCols.value = withMsgCols
    ? [
        { prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat },
        { prop: 'send_num', label: '发送消息数', width: 110, align: 'center' },
        { prop: 'receive_num', label: '接收消息数', width: 110, align: 'center' },
      ]
    : [{ prop: 'friend_nickname', label: '客户微信', minWidth: 160, onClick: goChat }]
  detailFetcher.value = async (page: number, page_size: number) =>
    getCustomers({ channel: 'wechat', owner_account_id: row.account_id, page, page_size })
  detailOpen.value = true
}

router 已在 L3 import/use。chat 路由名参照 CustomerList viewChat(L147-160 用 name:'chat')。implementer 确认 Customer 类型 import 路径(@/api/types)。

  • Step 2: 类型检查 + 浏览器验证(点沟通数 modal 内客户微信→跳该客户聊天记录页)
cd yunkes && npx vue-tsc -b
  • Step 3: Commit
git add yunkes/src/views/StaffList.vue
git commit -m "$(cat <<'EOF'
feat(staff): 微信员工明细 modal 内客户微信 → 聊天记录(二级操作)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 8: CustomerList 微信客户「导出HTML聊天记录」+ 时间选择

事实:微信 tab 操作列 L301-307(单按钮「查看聊天记录」);WhatsApp tab L373-379(勿改)。viewChat L147-160。云客导出HTML→先弹「请选择时间」modal→确定下载。downloadBlob(Task2)。导出参数:channel/owner_account_id/talker_id/start/end。

Files: Modify yunkes/src/views/CustomerList.vue

  • Step 1: 加导出相关状态 + 函数(script 内,viewChat 附近):
import { downloadBlob } from '@/api/client'

const exportOpen = ref(false)
const exportRow = ref<Customer | null>(null)
const exportRange = ref<[number, number] | null>(null)  // [startMs, endMs]

function openExport(row: Customer): void {
  if (!row.owner_account_id || !row.friend_id) {
    ElMessage.warning('该客户缺少归属账号/客户标识,无法导出')
    return
  }
  exportRow.value = row
  exportRange.value = null
  exportOpen.value = true
}

async function confirmExport(): Promise<void> {
  const row = exportRow.value
  if (!row) return
  const params: Record<string, unknown> = {
    channel: row.channel,
    owner_account_id: row.owner_account_id,
    talker_id: row.friend_id,
  }
  if (exportRange.value) {
    // value-format="X" 返回秒级字符串,后端要毫秒
    params.start = Number(exportRange.value[0]) * 1000
    params.end = Number(exportRange.value[1]) * 1000
  }
  exportOpen.value = false
  await downloadBlob('/export/chat-html', params)
}

ref/ElMessage 已在文件顶部 import(确认);Customer 类型已用。时间范围用 element-plus date-picker(毫秒时间戳)。

  • Step 2: 微信 tab 操作列加导出按钮 + 加宽(L301-307):
<el-table-column label="操作" width="220" fixed="right" align="center">
  <template #default="{ row }">
    <el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
    <el-button link type="primary" size="small" @click.stop="openExport(row)">导出HTML聊天记录</el-button>
  </template>
</el-table-column>

仅微信 tab(L301-307)改,WhatsApp tab(L373-379)不动(云客 WA 客户无导出按钮)。

  • Step 3: 加时间选择 dialog(模板末尾,关闭标签前):
<el-dialog v-model="exportOpen" title="请选择时间" width="420px">
  <el-form label-width="90px">
    <el-form-item label="时间范围">
      <el-date-picker
        v-model="exportRange"
        type="datetimerange"
        range-separator="至"
        start-placeholder="开始时间"
        end-placeholder="结束时间"
        value-format="X"
        format="YYYY-MM-DD HH:mm:ss"
        style="width:100%"
      />
    </el-form-item>
  </el-form>
  <template #footer>
    <el-button @click="exportOpen = false">取 消</el-button>
    <el-button type="primary" @click="confirmExport">确 定</el-button>
  </template>
</el-dialog>

value-format="X" 返回秒级 unix 字符串;需转毫秒。改 confirmExport 里:params.start = Number(exportRange.value[0]) * 1000(若 date-picker 返回字符串)。implementer 按 element-plus 实际返回类型调整(X 返回 string 秒)。

  • Step 4: 类型检查 + 浏览器验证(微信客户 tab→点导出→弹时间选择→确定→下载 HTML 含消息;不选时间→导出全部)
cd yunkes && npx vue-tsc -b
  • Step 5: Commit
git add yunkes/src/views/CustomerList.vue
git commit -m "$(cat <<'EOF'
feat(customers): 微信客户操作列加「导出HTML聊天记录」+ 时间选择

对齐云客:先弹「请选择时间」→ 确定后调 /api/export/chat-html 下载。

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 9: GroupList 微信群「导出HTML聊天记录」

事实:微信群 tab 操作列 L270-276(单按钮);WA 群 L345-351(勿改,云客 WA 群无导出)。viewChat L137-146。群导出用 room_id(非 talker_id)。

Files: Modify yunkes/src/views/GroupList.vue

  • Step 1: 加 exportGroupHtml 函数(script 内,viewChat 附近):
import { downloadBlob } from '@/api/client'

/** 微信群「导出HTML聊天记录」→ 后端 /api/export/chat-html(room_id)。云客此操作在微信群列表。 */
async function exportGroupHtml(row: Group): Promise<void> {
  if (!row.owner_account_id || !row.room_id) {
    ElMessage.warning('该群缺少归属账号/群标识,无法导出')
    return
  }
  await downloadBlob('/export/chat-html', {
    channel: row.channel,
    owner_account_id: row.owner_account_id,
    room_id: row.room_id,
  })
}

群导出不加时间选择(云客群列表导出按钮直接导出,无时间 modal——仅微信客户有「请选择时间」)。若实测发现群也有时间选择,implementer 补;本 plan 按客户列表采集事实(群操作列点采集时未见时间 modal,直接下载)。Group/ElMessage 已 import。

  • Step 2: 微信群 tab 操作列加导出按钮 + 加宽(L270-276):
<el-table-column label="操作" width="220" fixed="right" align="center">
  <template #default="{ row }">
    <el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
    <el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML聊天记录</el-button>
  </template>
</el-table-column>

仅微信群 tab(L270-276)改,WA 群(L345-351)不动。

  • Step 3: 类型检查 + 浏览器验证(微信群 tab→点导出→下载 HTML 含群消息)
cd yunkes && npx vue-tsc -b
  • Step 4: Commit
git add yunkes/src/views/GroupList.vue
git commit -m "$(cat <<'EOF'
feat(groups): 微信群操作列加「导出HTML聊天记录」(补 Plan3 漏的操作)

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 10: 端到端验证 + 回归

  • Step 1: 起后端 + 前端(若未在跑)。
  • Step 2: 后端 curl 抽查(用已有 wechat customer 的 owner+friend):
curl -s -o /tmp/chat.html "http://localhost:8000/api/export/chat-html?channel=wechat&owner_account_id=wxid_fnkf5b0iaqrb22&talker_id=wxid_p6417n8wveno22" && head -c 300 /tmp/chat.html

预期:返回 HTML(含 <title>聊天记录 + 消息文本)。无消息则「暂无消息」。

  • Step 3: 浏览器 /staff 微信 tab:① 点沟通人数/有效沟通数/跟进客户数/员工最后回复数/未回复客户数 → 各弹对应标题 modal(有效沟通数多2列);② modal 内点客户微信 → 跳聊天记录;③ 点主badge → 跳聊天;④ 点员工姓名 → 开 drawer;⑤ 手机号不可点。
  • Step 4: 浏览器 /customers 微信 tab → 点导出HTML → 选时间 → 确定 → 下载 HTML(含消息)。
  • Step 5: 浏览器 /groups 微信群 tab → 点导出HTML → 下载 HTML。
  • Step 6: 全回归
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
cd yunkes && npx vue-tsc -b

预期:pytest 全绿(≥333),vue-tsc exit 0。


收尾验收

  • vue-tsc -b exit 0;pytest ≥333 绿(新增 3 export 测试)。
  • 微信员工 5 数字均可点→弹对应 modal(有效沟通数多发送/接收消息数列);回复率不可点;主badge→聊天;员工姓名→drawer;手机号不可点;modal 内客户微信→聊天记录。
  • 微信客户导出HTML:时间选择 dialog → 下载 HTML。
  • 微信群导出HTML:直接下载 HTML。
  • WA 两侧(员工/客户/群)操作不受影响(Plan1/2/3 已对齐,本 plan 未改 WA)。
  • 数据源简化已确认产品可接受:5 个数字 modal 统一用员工客户列表,度量精确区分(已回复 vs 未回复)留待后续 message 聚合端点。

不在本 plan 范围(显式)

  • 语音通话列表 CallList(微信 voice-call-list + WA wavoicecalllist + 长语音列表 modal + 录音播放/手动匹配/录音异常检查)—— 独立 Plan 4,前置抓取修复(voice-call-list/wavoicecalllist 抓取缺失)。
  • 工具栏批量操作(批量语音下载/全部导出/高级筛选)—— 随 CallList。
  • 员工详情独立页(用户选降级 drawer,不建)。
  • 5 个数字 modal 的精确 KPI 度量还原(需 message 聚合端点,独立增强)。
  • 导出 HTML 的真实文件名(响应拦截器限制,用固定名;如需真实文件名另开 getRaw helper)。