chore: start_backend.sh启动脚本+plan4a/4b历史计划文档
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
# Plan 4a: WA 语音通话抓取 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:** 新增 WhatsApp 语音通话抓取(`scrape_wa_voice_calls`),复用云客统一语音接口 `queryWeChatVoiceList` + `callSource:"4"`,存入 `PhoneCall` 表(`channel='wa_voice_call'`),并挂进 scheduler + smoke 实跑验证,为 Plan 4b(CallList UI 对齐 WA tab)提供数据。
|
||||
|
||||
**Architecture:** 复用现有 `scrape_wechat_voice_calls`(`app/scraper/wechat.py:1222`)的翻页/解析/upsert 模式,在 `app/scraper/whatsapp.py` 新增 `scrape_wa_voice_calls`——唯一差异:请求 body 加 `callSource:"4"`(=WA;微信=`"0"`)、归一化 `channel="wa_voice_call"`。**不加 PhoneCall 列、不做 Alembic 迁移**(yunke 用 `Base.metadata.create_all`,无 alembic 目录):WA 特有字段(`weChatId`=ownerWA/`callFileId`/`nextId`/`isRoom`)落 `raw_json`,员工用现有 `user_id` 列筛。
|
||||
|
||||
**Tech Stack:** Python 3.12 + httpx + respx(scraper 测试)+ FastAPI/SQLAlchemy(复用)。
|
||||
|
||||
**Spec/事实依据:**
|
||||
- `docs/superpowers/specs/2026-08-06-yunke-comm-operations-audit.md`(核对表 §6 WA 语音 5 列 + 操作)
|
||||
- **Phase 0 浏览器采集(2026-08-06/07)**:WA `wavoicecalllist` 页数据请求 = `POST /wechatweb/callRecord/queryWeChatVoiceList`,body `{"pageNum":1,"pageSize":10,"startTime":"...","endTime":"...","departmentId":"...","isRoom":"0","callSource":"4","ids":[],"userId":""}`,response `data.total=143` + 每行字段(见下)。**微信页同接口 `callSource:"0"`**。
|
||||
- **代码分析(Explore)**:`scrape_wechat_voice_calls`(wechat.py:1222)已实现且 URL 正确;smoke.db `phone_call` 0 行是因 `scripts/smoke_wechat.py` 没调语音 scraper(非代码错);whatsapp.py 无 voice scraper;`upsert_voice_call`(repos.py:515)复用 `_upsert_one(PhoneCall)`;PhoneCall 无 `owner_account_id` 列(weChatId 落 raw_json)。
|
||||
|
||||
---
|
||||
|
||||
## 范围
|
||||
|
||||
**本 plan 覆盖(4a 抓取)**:
|
||||
- 新增 `scrape_wa_voice_calls`(whatsapp.py)+ TDD 测试
|
||||
- 新增 `sync_wa_voice_calls`(sync 编排)+ scheduler `ENTITY_FUNCS["wa_voice_calls"]` + 接进 `_run_all_wechat`/`_run_all`
|
||||
- smoke 脚本挂载 WA + 微信语音 scraper 实跑,验证填 smoke.db
|
||||
|
||||
**不在本 plan 范围**:
|
||||
- CallList UI 对齐(WA tab/列结构/长语音 modal/统计卡片/录音异常检查/工具栏)—— **Plan 4b**(依赖本 plan 产出的真实数据字段)。
|
||||
- 长语音片段抓取(`/wechatweb/call/list`)—— 随 Plan 4b 长语音 modal。
|
||||
- 微信语音 scraper 改造(现状能用;若 smoke 实测返回含 WA 数据,本 plan Task 4 补 `callSource:"0"` 加固)。
|
||||
- PhoneCall 加列 / Alembic 迁移(刻意避免:无 alembic 目录;channel 区分 + raw_json 足够)。
|
||||
|
||||
### WA 语音字段映射(Phase 0 实测 response → PhoneCall 列)
|
||||
|
||||
| 云客字段 | PhoneCall 列 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | `call_id` (PK) | |
|
||||
| — | `channel` = `"wa_voice_call"` | 区分微信(`voice_call`)/电话(`phone_call`) |
|
||||
| `startTime` `"2026-08-06 11:28:22"` | `start_call_time` | `%Y-%m-%d %H:%M:%S` → ms epoch(naive UTC,同微信语音) |
|
||||
| `duration` `"82"` | `call_duration` | 字符串秒 → int |
|
||||
| `callStatus` (1=接通有录音/2=未接通) | `call_status` | int |
|
||||
| `isSend` (0=呼入/1=呼出) | `incoming_call` | **翻转** `1 - isSend`(同微信语音,对齐 PhoneCall 极性 1=呼入)|
|
||||
| `userId` | `user_id` | 员工 |
|
||||
| `userName` | `user_id_name` | 员工姓名 |
|
||||
| `talker` (`584142462772@s.whatsapp.net`) | `call_number` | 客户 WA id |
|
||||
| `talkerNickName` | `plan_customer_name` | 脱敏昵称 |
|
||||
| `ossFileName` (mp3 URL 或 null) | `record_file` | 录音直链 |
|
||||
| `version` | `version` | |
|
||||
| `weChatId`/`callFileId`/`nextId`/`isRoom`/`from` 等 | `raw_json` | WA 特有,无对应列 |
|
||||
|
||||
---
|
||||
|
||||
## 运行命令(验证用)
|
||||
|
||||
- 单测:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wa_voice_calls.py -q`
|
||||
- 全后端:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q`
|
||||
- smoke 实跑(填 smoke.db):`cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m app.sync.cli sync --entity wa_voice_calls`(.env 有 YK_USERID/COMPANY/COOKIE)
|
||||
- 查 smoke.db:`cd yunke-server && .venv/Scripts/python.exe -c "import sqlite3;c=sqlite3.connect('smoke.db');print(c.execute(\"select channel,count(*) from phone_call group by channel\").fetchall())"`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify `app/scraper/whatsapp.py` — 新增 `scrape_wa_voice_calls` + `_build_wa_voice_body`/`_normalize_wa_voice_call`/`_parse_wa_voice_total`/`_parse_wa_voice_rows` + 常量 + `__all__`
|
||||
- Create `tests/test_scrape_wa_voice_calls.py` — TDD(respx + cassette,照 `test_scrape_wechat_voice_calls.py`)
|
||||
- Create `tests/cassettes/wa_voice_fixture.py` — WA 响应 fixture(照 `voiceCall_fixture.py`)
|
||||
- Modify sync 编排(`app/sync/` 下含 `sync_wechat_voice_calls` 的模块)— 新增 `sync_wa_voice_calls`
|
||||
- Modify `app/sync/scheduler.py` — `ENTITY_FUNCS["wa_voice_calls"]` + 接进 `_run_all_wechat`/`_run_all`
|
||||
- Modify `scripts/smoke_wechat.py`(或新 `scripts/smoke_calls.py`)— 挂载 voice/wa_voice/phone/sms scraper
|
||||
|
||||
---
|
||||
|
||||
## Task 1: WA 语音 scraper + 解析(TDD)
|
||||
|
||||
> 事实:`scrape_wechat_voice_calls`(wechat.py:1222)用 `paginate_by_page` + `VOICE_LIST_PATH="/wechatweb/callRecord/queryWeChatVoiceList"` + `_parse_voice_total`(读 `data.total`)/`_parse_voice_rows`(读 `data.data`)+ `_build_voice_body`(`{pageNum,pageSize}`)+ `_normalize_voice_call`。WA 用**同接口同 envelope**,仅 body 加 `callSource:"4"`、channel 改 `wa_voice_call`。`paginate_by_page` 签名见 `scraper/base.py`。`coerce_int_or_none` + `parse_datetime_to_ms` 都从 `app.scraper.parsers`(whatsapp.py:36 已 import,直接用)。
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/cassettes/wa_voice_fixture.py`
|
||||
- Create: `tests/test_scrape_wa_voice_calls.py`
|
||||
- Modify: `app/scraper/whatsapp.py`
|
||||
|
||||
- [ ] **Step 1: 写 WA 响应 fixture**(`tests/cassettes/wa_voice_fixture.py`,照 `tests/cassettes/voiceCall_fixture.py` 模式):导出 `WA_VOICE_RECORDS`(2-3 条 WA 记录,字段对齐 Phase 0 实测:`id`/`userId`/`userName`/`userWeChatNickName`/`userWeChatAlias`/`talker`/`talkerNickName`/`talkerAlias`/`callType`/`isSend`/`callStatus`/`ossFileName`/`duration`/`startTime`/`weChatId`/`callFileId`/`nextId`/`isRoom`/`version`)+ `make_wa_voice_list_response(records, total, page_num, page_size)`(返回 `{"message":"调用成功","code":"10000","data":{"pageNum":...,"pageSize":...,"total":total,"data":records}}` envelope)。先读 `voiceCall_fixture.py` 对齐 `make_voice_list_response` 的确切结构。
|
||||
|
||||
- [ ] **Step 2: 写失败测试**(`tests/test_scrape_wa_voice_calls.py`,照 `test_scrape_wechat_voice_calls.py` 的 respx 模式):
|
||||
```python
|
||||
from __future__ import annotations
|
||||
import httpx, respx
|
||||
from app.scraper.whatsapp import scrape_wa_voice_calls
|
||||
from app.session.yunke_client import YunkeClient
|
||||
from tests.cassettes.wa_voice_fixture import WA_VOICE_RECORDS, TOTAL, make_wa_voice_list_response
|
||||
|
||||
BASE = "https://crm2.yunkecn.com"
|
||||
VOICE_URL = f"{BASE}/wechatweb/callRecord/queryWeChatVoiceList"
|
||||
|
||||
def _primed_client() -> YunkeClient:
|
||||
return YunkeClient(base_url=BASE, userid="a"*32, company="8m0atd")
|
||||
|
||||
@respx.mock
|
||||
async def test_scrape_wa_voice_calls_yields_records_and_paginates():
|
||||
"""两页 + 空页终止 -> 全部记录;body 含 callSource=4;channel=wa_voice_call。"""
|
||||
page1 = make_wa_voice_list_response(WA_VOICE_RECORDS[:2], total=TOTAL, page_num=1, page_size=2)
|
||||
page2 = make_wa_voice_list_response(WA_VOICE_RECORDS[2:], total=TOTAL, page_num=2, page_size=2)
|
||||
page3 = make_wa_voice_list_response([], total=TOTAL, page_num=3, page_size=2)
|
||||
route = respx.post(VOICE_URL).mock(side_effect=[
|
||||
httpx.Response(200, json=page1), httpx.Response(200, json=page2), httpx.Response(200, json=page3)])
|
||||
client = _primed_client()
|
||||
try:
|
||||
results = [r async for r in scrape_wa_voice_calls(client, page_size=2)]
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert route.call_count == 3
|
||||
assert len(results) == len(WA_VOICE_RECORDS)
|
||||
import json as _json
|
||||
body1 = _json.loads(route.calls[0].request.content.decode())
|
||||
assert body1["callSource"] == "4" # 关键:WA 区分
|
||||
assert body1["pageNum"] == 1 and body1["pageSize"] == 2
|
||||
for result, record in zip(results, WA_VOICE_RECORDS, strict=True):
|
||||
assert result["channel"] == "wa_voice_call"
|
||||
assert result["call_id"] == record["id"]
|
||||
assert result["call_number"] == record["talker"] # friendWA
|
||||
assert result["record_file"] == record["ossFileName"]
|
||||
assert result["call_status"] == record["callStatus"]
|
||||
assert result["raw_json"] == record # weChatId/callFileId 保 raw_json
|
||||
# incoming_call 翻转
|
||||
assert result["incoming_call"] == 1 - record["isSend"]
|
||||
|
||||
@respx.mock
|
||||
async def test_scrape_wa_voice_calls_stops_on_empty_first_page():
|
||||
empty = make_wa_voice_list_response([], total=0, page_num=1)
|
||||
route = respx.post(VOICE_URL).mock(return_value=httpx.Response(200, json=empty))
|
||||
client = _primed_client()
|
||||
try:
|
||||
results = [r async for r in scrape_wa_voice_calls(client)]
|
||||
finally:
|
||||
await client.aclose()
|
||||
assert results == [] and route.call_count == 1
|
||||
```
|
||||
> implementer:参照 `test_scrape_wechat_voice_calls.py` 的 `_ms`/断言风格补全(start_call_time 的 ms 断言等)。先确认 `voiceCall_fixture.py` 的 `TOTAL`/`DEFAULT_PAGE_SIZE` 命名,WA fixture 对齐。
|
||||
|
||||
- [ ] **Step 3: 跑测试确认 FAIL**
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wa_voice_calls.py -x -q
|
||||
```
|
||||
预期:FAIL(`scrape_wa_voice_calls` 不存在,ImportError)。
|
||||
|
||||
- [ ] **Step 4: 实现 WA scraper**(`app/scraper/whatsapp.py`,加在文件末尾,import `paginate_by_page` 已在 L35):
|
||||
```python
|
||||
# ===========================================================================
|
||||
# Voice-call scraper — POST /wechatweb/callRecord/queryWeChatVoiceList (callSource=4)
|
||||
# 复用云客统一语音接口(微信 callSource=0 / WA callSource=4);envelope 同微信语音
|
||||
# (data.data / data.total)。channel="wa_voice_call" 区分。
|
||||
# ===========================================================================
|
||||
|
||||
WA_VOICE_LIST_PATH = "/wechatweb/callRecord/queryWeChatVoiceList"
|
||||
|
||||
|
||||
def _parse_wa_voice_total(resp: Any) -> int | None:
|
||||
"""data.total(同微信语音 envelope)。"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except (AttributeError, ValueError):
|
||||
return None
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
total = data.get("total")
|
||||
return int(total) if isinstance(total, (int, float)) and not isinstance(total, bool) else None
|
||||
|
||||
|
||||
def _parse_wa_voice_rows(resp: Any) -> list[dict[str, Any]]:
|
||||
"""data.data(同微信语音 envelope)。"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except (AttributeError, ValueError):
|
||||
return []
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
rows = data.get("data")
|
||||
return rows if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def _build_wa_voice_body(page_num: int, page_size: int) -> dict[str, Any]:
|
||||
"""WA 语音请求体:分页 + callSource=4(WA)。"""
|
||||
return {"pageNum": page_num, "pageSize": page_size, "callSource": "4"}
|
||||
|
||||
|
||||
def _normalize_wa_voice_call(record: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map one WA queryWeChatVoiceList record -> PhoneCall shape (channel="wa_voice_call")。
|
||||
|
||||
字段映射同微信语音(_normalize_voice_call),唯二差异:channel 标 wa_voice_call;
|
||||
WA 特有字段(weChatId=ownerWA / callFileId / nextId / isRoom / from)无对应列,落 raw_json。
|
||||
isSend 翻转极性(0=呼入 -> incoming_call=1)。None-safe。
|
||||
"""
|
||||
is_send = coerce_int_or_none(record.get("isSend"))
|
||||
incoming_call = None if is_send is None else 1 - is_send
|
||||
return {
|
||||
"call_id": record.get("id"),
|
||||
"channel": "wa_voice_call",
|
||||
"start_call_time": parse_datetime_to_ms(record.get("startTime"), _WA_DATETIME_FORMATS),
|
||||
"call_duration": coerce_int_or_none(record.get("duration")),
|
||||
"call_status": coerce_int_or_none(record.get("callStatus")),
|
||||
"incoming_call": incoming_call,
|
||||
"user_id": record.get("userId"),
|
||||
"user_id_name": record.get("userName"),
|
||||
"call_number": record.get("talker"),
|
||||
"plan_customer_name": record.get("talkerNickName"),
|
||||
"record_file": record.get("ossFileName"),
|
||||
"version": record.get("version"),
|
||||
"raw_json": record,
|
||||
}
|
||||
|
||||
|
||||
async def scrape_wa_voice_calls(
|
||||
client: YunkeClient,
|
||||
*,
|
||||
page_size: int = 50,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Yield normalized WA voice-call records (queryWeChatVoiceList + callSource=4)."""
|
||||
async for record in paginate_by_page(
|
||||
client,
|
||||
WA_VOICE_LIST_PATH,
|
||||
page_size=page_size,
|
||||
parse_total=_parse_wa_voice_total,
|
||||
parse_rows=_parse_wa_voice_rows,
|
||||
build_body=_build_wa_voice_body,
|
||||
):
|
||||
yield _normalize_wa_voice_call(record)
|
||||
```
|
||||
> 注意:`coerce_int_or_none`/`parse_datetime_to_ms` 从 `app.scraper.parsers`(L36 已 import)。`_WA_DATETIME_FORMATS`(L50)含 `%Y-%m-%d %H:%M:%S`,WA startTime 是这格式——直接用(比微信的 `_parse_voice_start_time` 更简,复用 WA 既有格式列表)。把 `"scrape_wa_voice_calls"` 加入 `__all__`(L39-43)。
|
||||
|
||||
- [ ] **Step 5: 跑测试确认 PASS**
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wa_voice_calls.py -x -q
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 全后端回归**
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
|
||||
```
|
||||
预期:全绿(原 ≥333 + 新增)。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
git add yunke-server/app/scraper/whatsapp.py yunke-server/tests/test_scrape_wa_voice_calls.py yunke-server/tests/cassettes/wa_voice_fixture.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(scraper): WA 语音通话抓取 scrape_wa_voice_calls(callSource=4)
|
||||
|
||||
复用云客统一语音接口 queryWeChatVoiceList + callSource=4;channel=wa_voice_call
|
||||
区分;WA 特有字段落 raw_json;复用 PhoneCall 表 + upsert_voice_call。
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: sync 编排 + scheduler 注册
|
||||
|
||||
> 事实:`scheduler.py:149-152` `_run_voice_calls` → `sync_wechat_voice_calls`(在 `app/sync/` 下,grep 定位)。`ENTITY_FUNCS`(scheduler.py:212-225)含 `"voice_calls": _run_voice_calls`。`_run_all_wechat`(:182-193)顺序含 voice_calls;`_run_all`(:196-206)= _run_all_wechat + phone_calls + sms。`upsert_voice_call`(repos.py:515)。
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/sync/`(含 `sync_wechat_voice_calls` 的模块,grep 定位)
|
||||
- Modify: `app/sync/scheduler.py`
|
||||
|
||||
- [ ] **Step 1: 照写 `sync_wa_voice_calls`(orchestrator.py)**。`sync_wechat_voice_calls` 在 `app/sync/orchestrator.py:487`(签名 `(session: AsyncSession, client: YunkeClient) -> SyncResult`,调 `scrape_wechat_voice_calls` + `upsert_voice_call`)。读它,紧邻照写 `sync_wa_voice_calls(session, client)`(调 `scrape_wa_voice_calls` + `upsert_voice_call`,其余 session/日志/计数/SyncResult 照搬)。import `scrape_wa_voice_calls` from `app.scraper.whatsapp`。
|
||||
|
||||
- [ ] **Step 2: 接进 daily 序列 + scheduler ENTITY_FUNCS**。
|
||||
(a) **daily 序列在 orchestrator.py,不是 scheduler.py**:`sync_all_wechat`(orchestrator.py 约 :576-582)的串行序列里,在 `await sync_wechat_voice_calls(session, client)` 那行后插入 `await sync_wa_voice_calls(session, client),`。这让 `_run_all`/daily cron 覆盖 WA voice(scheduler 的 `_run_all_wechat` 只是委托给 `sync_all_wechat`,实体顺序在后者)。
|
||||
(b) **scheduler ENTITY_FUNCS**(单实体 CLI 用):`scheduler.py` 加 helper——**照 `_run_voice_calls`(:149-152)签名 `(session, client, store) -> list[SyncResult]`,不是 `(client)->int`**:
|
||||
```python
|
||||
async def _run_wa_voice_calls(session, client, store) -> list[SyncResult]:
|
||||
return [await sync_wa_voice_calls(session, client)]
|
||||
```
|
||||
`ENTITY_FUNCS` 加 `"wa_voice_calls": _run_wa_voice_calls`。import `sync_wa_voice_calls`(from orchestrator)+ `SyncResult`。
|
||||
|
||||
- [ ] **Step 3: 回归**
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q
|
||||
```
|
||||
(若 scheduler 有测试,确认 ENTITY_FUNCS 含新键;若无 scheduler 测试则全绿即可。)
|
||||
|
||||
- [ ] **Step 4: CLI 单实体冒烟(可选,需 cookie)**
|
||||
```bash
|
||||
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m app.sync.cli sync --entity wa_voice_calls
|
||||
```
|
||||
预期:不报错,抓取若干 WA 语音(143 total,按 page_size 翻页)。若 cookie 失效会报 401——则跳过,靠 Task 3 smoke 验证。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add yunke-server/app/sync/ yunke-server/app/sync/scheduler.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(sync): sync_wa_voice_calls + scheduler ENTITY_FUNCS wa_voice_calls
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: smoke 脚本挂载 + 实跑验证
|
||||
|
||||
> 事实:`scripts/smoke_wechat.py` 只调 `scrape_wechat_friends` + `scrape_wechat_messages`(smoke.db voice 0 行根因)。CLI `app.sync.cli sync --entity <name>` 走 ENTITY_FUNCS。
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/smoke_wechat.py`(或新建 `scripts/smoke_calls.py`)
|
||||
|
||||
- [ ] **Step 1: 挂载语音 scraper 到 smoke**。读 `scripts/smoke_wechat.py`,在 friends/messages 调用旁加 `voice_calls` + `wa_voice_calls`(+ 可选 phone_calls/sms)的 sync 调用。或新建 `scripts/smoke_calls.py` 调 `sync_wechat_voice_calls` + `sync_wa_voice_calls` + `sync_phone_calls` + `sync_sms`。参照现有 smoke 脚本的 client 构造(cookie from .env)+ DB_URL 写入 smoke.db 模式。
|
||||
|
||||
- [ ] **Step 2: 实跑填 smoke.db**(需 .env 有效 cookie):
|
||||
```bash
|
||||
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe scripts/smoke_calls.py
|
||||
```
|
||||
(或逐实体 CLI:`... sync --entity voice_calls` 然后 `wa_voice_calls`)
|
||||
|
||||
- [ ] **Step 3: 验证 smoke.db 有数据**
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -c "import sqlite3;c=sqlite3.connect('smoke.db');print(c.execute(\"select channel,count(*) from phone_call group by channel\").fetchall())"
|
||||
```
|
||||
预期:含 `('voice_call', N)` + `('wa_voice_call', M)`(M≈143 若全抓)。若 voice_call 仍 0,说明微信 scraper 不带 callSource 返回空/异常 → 触发 Task 4。
|
||||
|
||||
- [ ] **Step 4: 后端 API 抽查**
|
||||
```bash
|
||||
curl -s "http://localhost:8000/api/calls?channel=wa_voice_call&page_size=3" | head -c 500
|
||||
```
|
||||
(需后端跑:`uvicorn app.api.main:app --port 8000`)预期:返回 WA 语音通话 items。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add yunke-server/scripts/smoke_calls.py yunke-server/scripts/smoke_wechat.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(scripts): smoke 挂载 voice/wa_voice/phone/sms scraper(验证抓取填 smoke.db)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4(条件): 微信语音 callSource=0 加固
|
||||
|
||||
> **仅当 Task 3 实测发现**:微信 `scrape_wechat_voice_calls`(不带 callSource)返回的数据**含 WA 记录**(talker 是 `@s.whatsapp.net`)或返回空 → 才执行本 task。若微信语音正常返回(纯 wxid talker),跳过本 task。
|
||||
|
||||
**Files:** Modify `app/scraper/wechat.py` + `tests/test_scrape_wechat_voice_calls.py`
|
||||
|
||||
- [ ] **Step 1: 给 `_build_voice_body` 加 `call_source` 参数**(wechat.py:1143):
|
||||
```python
|
||||
def _build_voice_body(page_num: int, page_size: int, *, call_source: str = "0") -> dict[str, Any]:
|
||||
return {"pageNum": page_num, "pageSize": page_size, "callSource": call_source}
|
||||
```
|
||||
`scrape_wechat_voice_calls` 调用处传 `call_source="0"`(或 paginate_by_page 的 build_body kwargs)。
|
||||
|
||||
- [ ] **Step 2: 更新现有测试**(`test_scrape_wechat_voice_calls.py` 的 body 断言,:232-238):加 `assert body1["callSource"] == "0"`。
|
||||
|
||||
- [ ] **Step 3: 回归 + smoke 复跑确认微信纯数据**
|
||||
```bash
|
||||
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wechat_voice_calls.py -q
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add yunke-server/app/scraper/wechat.py yunke-server/tests/test_scrape_wechat_voice_calls.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(scraper): 微信语音 body 加 callSource=0 明确区分(防混入 WA)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 收尾验收
|
||||
|
||||
- [ ] `pytest tests/ -q` 全绿(≥333 + 新增 WA voice 测试)。
|
||||
- [ ] smoke.db `phone_call` 表含 `channel='wa_voice_call'` 行(实跑验证抓取链路通)。
|
||||
- [ ] `GET /api/calls?channel=wa_voice_call` 返回 WA 语音数据。
|
||||
- [ ] scheduler `ENTITY_FUNCS` 含 `wa_voice_calls`,`_run_all` 覆盖。
|
||||
- [ ] **为 Plan 4b 留好的数据基础**:WA 语音字段(talker=friendWA、user_id=员工、record_file=录音URL、call_status/isSend)已结构化进 PhoneCall,raw_json 保留 weChatId/callFileId 供 4b modal/匹配用。
|
||||
|
||||
---
|
||||
|
||||
## 不在本 plan 范围(Plan 4b)
|
||||
|
||||
- CallList.vue 加 WA tab + WA 列结构(状态/客户信息/员工信息/通话时间和录音/操作)。
|
||||
- 长语音列表 modal(`/wechatweb/call/list` 抓取 + voice_clip 存储 + 匹配 UI)。
|
||||
- 统计卡片(todayStatistics)、录音异常检查(`/call/check/{id}` 代理)、批量语音下载/全部导出/高级筛选工具栏。
|
||||
- 微信好友/微信群 tab(isRoom 区分)—— 若 4b 需要,从 raw_json.isRoom 读或加列。
|
||||
@@ -0,0 +1,342 @@
|
||||
# Plan 4b: CallList UI 对齐(tab + 列结构)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:** 把 `CallList.vue` 从"单 radio 筛选 + 通用 8 列"改成对齐云客语音通话列表的 **tab 结构(微信语音 / WA 语音 / 电话)+ 各 tab 云客列结构**,纯前端,用 Plan 4a 抓取的真实数据(voice_call 微信 / wa_voice_call WA / phone_call 电话)。
|
||||
|
||||
**Architecture:** `CallList.vue` 用 `el-tabs`(微信语音/WA 语音/电话),tab 切换改 `getCalls` 的 `channel` 参数;每 tab 用对齐云客的列集(`el-table-column` 按 tab 渲染,复用现有 `cell()`/`readField()` 读 `raw_json` 字段 + 录音播放 dialog + 详情 drawer)。后端零改动(`/api/calls` 已支持 channel 筛选,`wa_voice_call` 是新 channel 值,PhoneCallOut 含 raw_json)。
|
||||
|
||||
**Tech Stack:** Vue3 + TS + Element Plus(纯前端)。
|
||||
|
||||
**Spec/事实依据:**
|
||||
- `docs/superpowers/specs/2026-08-06-yunke-comm-operations-audit.md` §3(微信 voice-call-list 列)+ §6(WA wavoicecalllist 列)
|
||||
- **Plan 4a 真实数据**:smoke.db `phone_call` 表含 `channel='voice_call'`(微信 2193)+ `channel='wa_voice_call'`(WA 608)+ `channel='phone_call'`(电话)。raw_json 含云客原字段(`userWeChatNickName`/`userWeChatAlias`/`userPhone`/`talker`/`talkerNickName`/`talkerAlias`/`ossFileName`/`duration`/`startTime`/`endTime`/`weChatId`/`callFileId`/`isRoom`/`callType`/`isSend`/`callStatus`/`from`)。
|
||||
- **CallList.vue 现状**(402 行):`callType` radio(all/phone_call/voice_call)+ 8 列(时间/员工/号码/方向/类型/时长/状态/录音)+ playRecord dialog + openDrawer + `cell(row, camelKey)`→`readField` 读 raw_json。
|
||||
- **后端 `/api/calls`**:`channel` 参数直筛 `PhoneCall.channel`;PhoneCallOut `from_attributes` 含 raw_json。前端 `getCalls({channel, page, page_size})`。
|
||||
|
||||
---
|
||||
|
||||
## 范围
|
||||
|
||||
**本 plan 覆盖(4b 纯前端 tab+列)**:
|
||||
- `CallList.vue` 改 `el-tabs`(微信语音 / WA 语音 / 电话),tab→channel 映射(`voice_call` / `wa_voice_call` / `phone_call`)。
|
||||
- 各 tab 对齐云客列结构:
|
||||
- 微信语音 tab:通话信息 / 客户信息 / 员工微信 / 员工信息 / 通话录音 / 通话时长(audit §3)
|
||||
- WA 语音 tab:状态 / 客户信息 / 员工信息 / 通话时间和录音(audit §6)
|
||||
- 电话 tab:保留现有通用列(电话非云客沟通板块,不强对齐)
|
||||
- 复用录音播放 dialog + 详情 drawer。
|
||||
|
||||
**不在本 plan 范围(留 4c)**:
|
||||
- 长语音列表 modal(`/wechatweb/call/list` 抓取 + voice_clip 表 + 编辑/手动匹配 UI)。
|
||||
- 统计卡片(todayStatistics)、录音异常检查(`/call/check/{id}` 代理)、批量语音下载/全部导出/高级筛选工具栏。
|
||||
- 微信好友/微信群二级 tab(isRoom)—— 本 plan 微信语音 tab 混合展示好友+群通话;若需分开,4c 加 client-side `raw_json.isRoom` 筛选。
|
||||
- 列内可点元素(客户信息→聊天等)—— audit 标注采信,4c 随二级操作。
|
||||
|
||||
### 字段映射(云客列 → yunkes PhoneCall/raw_json)
|
||||
|
||||
| 云客列 | 子字段 | yunkes 来源 |
|
||||
|---|---|---|
|
||||
| 通话信息 | 通话状态 / 通话类型 / 通话来源 | `call_status`(结构化)/ `channel`(voice_call/wa_voice_call)/ `raw_json.from` |
|
||||
| 客户信息 | 昵称 / 号 | `plan_customer_name`(←talkerNickName)/ `call_number`(←talker) |
|
||||
| 员工微信 | 昵称 / 号 | `raw_json.userWeChatNickName` / `raw_json.userWeChatAlias`(或 weChatId) |
|
||||
| 员工信息 | 姓名 / 手机 | `user_id_name` / `raw_json.userPhone` |
|
||||
| 状态(WA)| 通话状态 + 时长 | `call_status` / `call_duration` |
|
||||
| 通话录音 | 时长 / 播放 | `call_duration`(或 raw_json.duration)/ `record_file`(←ossFileName)→ playRecord |
|
||||
| 通话时间和录音 | 结束时间 + 录音 | `raw_json.endTime`(或 startTime)/ `record_file` |
|
||||
| 通话时长 | 时长 | `call_duration` |
|
||||
|
||||
---
|
||||
|
||||
## 运行命令(验证用)
|
||||
|
||||
- 前端:`cd yunkes && npm run dev`
|
||||
- 类型检查:`cd yunkes && npx vue-tsc -b`
|
||||
- 数据确认(后端跑):`curl -s "http://localhost:8000/api/calls?channel=wa_voice_call&page_size=2" | head -c 400`(含 raw_json 字段)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify `yunkes/src/views/CallList.vue` — 改 el-tabs + 每 tab 列定义 + tab→channel
|
||||
- Modify `yunkes/src/utils/callFields.ts` — `CALL_FIELD_LABELS` 补 WA/微信语音 raw_json 字段(userWeChatNickName/userWeChatAlias/userPhone/talker/talkerNickName/talkerAlias/weChatId/callFileId/from 等中文 label)
|
||||
- Modify `yunkes/src/utils/format.ts` — 若 `callTypeLabel` 不识别 `'wa_voice_call'`,加映射(WA 语音通话)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: CallList 改 el-tabs + tab→channel 映射
|
||||
|
||||
> 事实:CallList.vue 现用 `el-radio-group`(callType: 'all'/'phone_call'/'voice_call')+ `onTypeChange`。`getCalls({channel: callType, page, page_size})`。改用 `el-tabs` 三 tab(微信语音 voice_call / WA 语音 wa_voice_call / 电话 phone_call),tab 切换改 channel + 重新 load。
|
||||
|
||||
**Files:** Modify `yunkes/src/views/CallList.vue`
|
||||
|
||||
- [ ] **Step 1: callType 改 tab + 值**。把 `callType` ref 默认改 `'voice_call'`(微信语音优先,云客语音通话列表主入口)。`typeOptions` 改为 3 tab(去掉 'all',每 tab 对应一个 channel):
|
||||
```ts
|
||||
const callType = ref<CallChannel>('voice_call') // 默认微信语音
|
||||
|
||||
const tabs: { value: CallChannel; label: string }[] = [
|
||||
{ value: 'voice_call', label: '微信语音' },
|
||||
{ value: 'wa_voice_call', label: 'WA 语音' },
|
||||
{ value: 'phone_call', label: '电话' },
|
||||
]
|
||||
```
|
||||
> `CallChannel` 类型需加 `'wa_voice_call'`(见 Task 5 类型)。先确认 `@/api/types` 的 `CallChannel` 定义(现 `'phone_call' | 'voice_call'`),加 `'wa_voice_call'`。
|
||||
|
||||
- [ ] **Step 2: 模板 radio 改 el-tabs**(CallList.vue :134-144 的 `yk-calls__filters` 区):
|
||||
```html
|
||||
<el-tabs v-model="callType" class="yk-calls__tabs" @tab-change="onTabChange">
|
||||
<el-tab-pane v-for="t in tabs" :key="t.value" :label="t.label" :name="t.value" />
|
||||
</el-tabs>
|
||||
```
|
||||
```ts
|
||||
function onTabChange(name: TabPaneName): void {
|
||||
callType.value = name as CallChannel
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
```
|
||||
> `TabPaneName` from 'element-plus'。移除 radio 相关:**header subline(:131 `类型:{{ typeLabelCurrent }}`)改用 `{{ tabs.find(t => t.value === callType)?.label }}`**,删 `typeOptions` + `typeLabelCurrent` computed(避免 vue-tsc 坏)。`onTabChange` 可简化为 `page.value=1; load()`(v-model 已更新 callType)。
|
||||
|
||||
- [ ] **Step 3: load() 用 channel**(:51-55 现已 `channel: callType.value`,保持;但不再传 'all'——每 tab 是具体 channel)。
|
||||
|
||||
- [ ] **Step 4: 类型检查**
|
||||
```bash
|
||||
cd yunkes && npx vue-tsc -b
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add yunkes/src/views/CallList.vue yunkes/src/api/types.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(calls): CallList 改 el-tabs(微信语音/WA 语音/电话)+ CallChannel 加 wa_voice_call
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 微信语音 tab 列对齐云客
|
||||
|
||||
> 事实:CallList.vue 现 8 列固定。云客微信 voice-call-list 列(audit §3):通话信息 / 客户信息 / 员工微信 / 员工信息 / 通话录音 / 通话时长。字段读 raw_json(`cell(row, 'userWeChatNickName')` 等)。微信语音 tab(callType==='voice_call')用云客列,其他 tab 用各自列(Task 3/4)。
|
||||
|
||||
**Files:** Modify `yunkes/src/views/CallList.vue`
|
||||
|
||||
- [ ] **Step 1: 微信语音 tab 列**。在 el-table 内,微信语音列用 `v-if="callType === 'voice_call'"` 包一组 el-table-column:
|
||||
```html
|
||||
<template v-if="callType === 'voice_call'">
|
||||
<el-table-column label="通话信息" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>通话状态:{{ callStatusLabel(row.call_status) }}</div>
|
||||
<div class="yk-cell__sub">类型:{{ callTypeLabel(row.channel) }}</div>
|
||||
<div class="yk-cell__sub">来源:{{ cell(row, 'from') ?? '--' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户信息" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.plan_customer_name || cell(row, 'talkerNickName') || '--' }}</div>
|
||||
<div class="yk-cell__sub">{{ row.call_number || cell(row, 'talker') || '--' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="员工微信" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div>{{ cell(row, 'userWeChatNickName') || '--' }}</div>
|
||||
<div class="yk-cell__sub">{{ cell(row, 'userWeChatAlias') || cell(row, 'weChatId') || '--' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="员工信息" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.user_id_name || '--' }}</div>
|
||||
<div class="yk-cell__sub">{{ cell(row, 'userPhone') || '--' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通话录音" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.record_file || cell(row, 'recordFile')" size="small" type="primary" link @click.stop="playRecord(row)">播放</el-button>
|
||||
<span v-else style="color:#c0c4cc">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通话时长" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
```
|
||||
> `callStatusLabel`/`callTypeLabel`/`formatCallDuration`/`cell`/`playRecord` 已在 CallList import/定义。`yk-cell__sub` 加样式(小字次要行,:style 区加 `.yk-cell__sub{color:#909399;font-size:12px}`)。
|
||||
|
||||
- [ ] **Step 2: 类型检查 + 浏览器验证**(`/calls` 微信语音 tab → 云客 6 列,有数据)
|
||||
```bash
|
||||
cd yunkes && npx vue-tsc -b
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add yunkes/src/views/CallList.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(calls): 微信语音 tab 对齐云客列(通话信息/客户/员工微信/员工信息/录音/时长)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: WA 语音 tab 列对齐云客
|
||||
|
||||
> 事实:云客 WA wavoicecalllist 列(audit §6):状态 / 客户信息 / 员工信息 / 通话时间和录音 / 操作。本 plan 操作列留 4c(无操作按钮,仅展示),其余列对齐。
|
||||
|
||||
**Files:** Modify `yunkes/src/views/CallList.vue`
|
||||
|
||||
- [ ] **Step 1: WA 语音 tab 列**。`v-if="callType === 'wa_voice_call'"` 包一组:
|
||||
```html
|
||||
<template v-if="callType === 'wa_voice_call'">
|
||||
<el-table-column label="状态" width="160">
|
||||
<template #default="{ row }">
|
||||
<div>{{ callStatusLabel(row.call_status) }}</div>
|
||||
<div class="yk-cell__sub">时长 {{ formatCallDuration(row.call_duration) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户信息" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.plan_customer_name || cell(row, 'talkerNickName') || '--' }}</div>
|
||||
<div class="yk-cell__sub">{{ cell(row, 'talkerAlias') || row.call_number || '--' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="员工信息" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.user_id_name || '--' }} · {{ cell(row, 'userPhone') || '--' }}</div>
|
||||
<div class="yk-cell__sub">{{ cell(row, 'userWeChatNickName') || '--' }} · {{ cell(row, 'userWeChatAlias') || '--' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通话时间和录音" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div>{{ formatTime(row.start_call_time) }}</div>
|
||||
<el-button v-if="row.record_file || cell(row, 'ossFileName')" size="small" type="primary" link @click.stop="playRecord(row)" style="margin-top:2px">播放录音</el-button>
|
||||
<span v-else class="yk-cell__sub">无录音</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
```
|
||||
> audit WA 操作列(手动匹配/录音异常检查)本 plan 不做(4c)。WA call_status 极性:Plan 4a 翻转了 incoming_call(isSend),callStatusLabel 用 call_status(接通/未接通,未翻转)——确认 callStatusLabel 对 WA 的 call_status(1接通/2未接通)显示对(format.ts 的 callStatusLabel)。
|
||||
|
||||
- [ ] **Step 2: 类型检查 + 浏览器验证**(`/calls` WA 语音 tab → 云客 4 列,608 条数据)
|
||||
```bash
|
||||
cd yunkes && npx vue-tsc -b
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add yunkes/src/views/CallList.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(calls): WA 语音 tab 对齐云客列(状态/客户/员工/通话时间和录音)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 电话 tab 保留现有通用列
|
||||
|
||||
> 事实:电话(phone_call)非云客沟通板块(云客电话 CRM 是另一套),yunkes 现有 8 列对电话适用。保留。
|
||||
|
||||
**Files:** Modify `yunkes/src/views/CallList.vue`
|
||||
|
||||
- [ ] **Step 1: 电话 tab 列**。`v-if="callType === 'phone_call'"` 包现有 8 列(时间/员工/号码/方向/类型/时长/状态/录音)。把现有 :167-207 的列移进这个 v-if template(保留 playRecord)。
|
||||
|
||||
- [ ] **Step 2: 类型检查 + 浏览器验证**(电话 tab → 现有 8 列)
|
||||
```bash
|
||||
cd yunkes && npx vue-tsc -b
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add yunkes/src/views/CallList.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(calls): 电话 tab 保留通用 8 列(按 channel 分离列集)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: callFields/format label 补 + 类型 + 验证
|
||||
|
||||
> 事实:`callFields.ts` CALL_FIELD_LABELS 已含部分(ossFileName/duration/startTime 等),但缺 WA/微信语音特有(userWeChatNickName/userWeChatAlias/userPhone/talker/talkerNickName/talkerAlias/weChatId/callFileId/from)。`format.ts` callTypeLabel 可能不识别 'wa_voice_call'。
|
||||
|
||||
**Files:**
|
||||
- Modify `yunkes/src/utils/callFields.ts`
|
||||
- Modify `yunkes/src/utils/format.ts`
|
||||
- Modify `yunkes/src/api/types.ts`
|
||||
|
||||
- [ ] **Step 1: CallChannel 类型加 wa_voice_call**(`api/types.ts`):
|
||||
```ts
|
||||
export type CallChannel = 'phone_call' | 'voice_call' | 'wa_voice_call'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: callTypeLabel 加 WA + callStatusLabel 加宽**(`format.ts`):
|
||||
```ts
|
||||
// callTypeLabel 加:'wa_voice_call' -> 'WA 语音通话'
|
||||
// callStatusLabel 加宽:WA 上游 callStatus 1=接通/2=未接通(现有 1=已接通/0=未接通)。
|
||||
// 改为:1 -> '已接通';0 | 2 -> '未接通';default -> `状态 ${v}`。
|
||||
// 先确认各 channel 极性(WA callStatus=2 实测是未接通);若 phone_call 用 0=未接通则兼容(0|2 都未接通)。
|
||||
```
|
||||
> 实测 WA callStatus:Phase 0 采集 callStatus=1 有录音(接通)/callStatus=2 无录音(未接通)。format.ts 现 callStatusLabel 只认 1/0,WA 的 2 会显示"状态 2"——加宽到 0|2=未接通。
|
||||
|
||||
- [ ] **Step 3: CALL_FIELD_LABELS 补 WA/微信语音字段**(`callFields.ts:24`):
|
||||
```ts
|
||||
// 在 CALL_FIELD_LABELS 加:
|
||||
userWeChatNickName: '员工微信昵称',
|
||||
userWeChatAlias: '员工微信号',
|
||||
userPhone: '员工手机',
|
||||
talker: '客户号',
|
||||
talkerNickName: '客户昵称',
|
||||
talkerAlias: '客户微信号',
|
||||
weChatId: '员工微信ID',
|
||||
callFileId: '录音文件ID',
|
||||
from: '通话来源',
|
||||
isRoom: '是否群通话',
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 全类型检查 + 浏览器端到端**
|
||||
```bash
|
||||
cd yunkes && npx vue-tsc -b
|
||||
```
|
||||
浏览器 `/calls`:① 微信语音 tab → 6 云客列 + 数据;② WA 语音 tab → 4 云客列 + 608 条;③ 电话 tab → 8 列;④ 录音播放(有录音行);⑤ 行点击→drawer 全字段(含新 label)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add yunkes/src/api/types.ts yunkes/src/utils/format.ts yunkes/src/utils/callFields.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(calls): CallChannel 加 wa_voice_call + callFields/format label 补 WA/微信语音字段
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 收尾验收
|
||||
|
||||
- [ ] `vue-tsc -b` exit 0。
|
||||
- [ ] `/calls` 三 tab(微信语音/WA 语音/电话)切换正常,各 tab 列对齐云客。
|
||||
- [ ] 微信语音 tab 6 列(通话信息/客户信息/员工微信/员工信息/通话录音/通话时长)+ 数据(2193 条)。
|
||||
- [ ] WA 语音 tab 4 列(状态/客户信息/员工信息/通话时间和录音)+ 数据(608 条)。
|
||||
- [ ] 录音播放 dialog 工作(有 record_file 行)。
|
||||
- [ ] 详情 drawer 全字段(新 label 显示)。
|
||||
- [ ] tab 切换重新 load(channel 变)。
|
||||
|
||||
---
|
||||
|
||||
## 不在本 plan 范围(Plan 4c)
|
||||
|
||||
- 长语音列表 modal(call/list 抓取 + voice_clip 表 + 编辑/手动匹配 UI)。
|
||||
- 统计卡片(todayStatistics 抓取/展示)。
|
||||
- 录音异常检查(/call/check/{id} 后端代理 + 按钮)。
|
||||
- 批量语音下载 / 全部导出 / 高级筛选工具栏。
|
||||
- 微信好友/微信群二级 tab(raw_json.isRoom client 筛)。
|
||||
- 列内可点元素(客户信息→聊天等二级操作)。
|
||||
Reference in New Issue
Block a user