docs: UI alignment spec + StaffList interaction plan
Spec: align all list/detail pages to 云客 (columns + interactions); covers interaction taxonomy, backend extensions (customer add_time/filters, /api/sessions), phase-0 capture. Plan 1: StaffList interaction closure + customer backend + YkStaffDetailModal. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,3 +24,4 @@ media/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -0,0 +1,726 @@
|
|||||||
|
# StaffList 交互补全 + customer 后端基础 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:** 把 StaffList(员工列表)的交互对齐云客——统计数字点击弹明细 modal、操作按钮跳转按员工筛选的列表、昵称跳聊天——并补齐所需后端能力(customer `add_time` 列 + 筛选参数),抽通用明细 modal 组件供后续页面复用。
|
||||||
|
|
||||||
|
**Architecture:** 后端先 TDD 扩展(Customer.add_time 列 + Alembic 迁移 + scraper 映射 + 回填脚本 + router 三个筛选参数),再前端:通用 `YkStaffDetailModal` 组件 + StaffList 三类交互接入 + CustomerList/GroupList 读 query 预筛。
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI + SQLAlchemy 2.0 async + Alembic(后端);Vue3 + TS + Element Plus + Pinia(前端)。
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-05-yunke-ui-alignment-design.md`
|
||||||
|
|
||||||
|
**已采集事实(WhatsApp 员工表交互,Playwright 验证):**
|
||||||
|
- 添加人数/沟通人数(数字)→ modal(明细表)
|
||||||
|
- 操作「好友列表」→ `/waBusiness/friendlist/0/{userId}`;「群聊」→ `friendlist/1/{userId}`
|
||||||
|
- WA 昵称 → 新 tab 聊天记录(`wschatroom`,会话列表+消息流,只读)
|
||||||
|
|
||||||
|
**运行命令(验证用):**
|
||||||
|
- 后端:`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 yunkes && npm run dev`
|
||||||
|
- 后端测试:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q`
|
||||||
|
- 前端类型:`cd yunkes && npx vue-tsc -b`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
**后端(yunke-server/):**
|
||||||
|
- Modify `app/db/models.py` — `Customer` 加 `add_time` 列
|
||||||
|
- Modify `app/api/schemas.py` — `CustomerOut` 加 `add_time`
|
||||||
|
- Create `migrations/versions/<rev>_customer_add_time.py` — Alembic 迁移
|
||||||
|
- Modify `app/scraper/wechat.py` — customer 映射加 `add_time`
|
||||||
|
- Modify `app/scraper/whatsapp.py` — customer 映射加 `add_time`
|
||||||
|
- Create `scripts/backfill_customer_add_time.py` — 解析 raw_json 回填历史
|
||||||
|
- Modify `app/api/routers/customer.py` — 加 `user_id`/`add_time_since`/`last_chat_since`
|
||||||
|
- Modify `tests/test_api_customer.py` — 新筛选参数测试
|
||||||
|
- Modify `tests/test_scrape_wechat_friends.py` / `test_scrape_whatsapp_friends.py` — add_time 映射测试
|
||||||
|
|
||||||
|
**前端(yunkes/src/):**
|
||||||
|
- Modify `api/types.ts` — `Customer` 加 `add_time`
|
||||||
|
- Modify `api/index.ts` — `CustomerQuery` + `getCustomers` 加三参数
|
||||||
|
- Create `components/YkStaffDetailModal.vue` — 通用明细 modal
|
||||||
|
- Modify `views/StaffList.vue` — 数字→modal、操作→跳转、昵称→聊天
|
||||||
|
- Modify `views/CustomerList.vue` / `views/GroupList.vue` — 读 `route.query` owner/user 预筛
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: 探测微信员工表交互(产出目标映射)
|
||||||
|
|
||||||
|
> WhatsApp 员工表交互已采集(见上)。微信员工表(`/cms/wechat/wechatlist`)列结构已采,但**数字是否可点击未探测**。本 task 用同一探测脚本补齐。
|
||||||
|
|
||||||
|
**Files:** 无代码改动,产出记录写入本 plan 末尾「探测结果」。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 浏览器登录云客 + 打开微信员工列表**(Playwright;若已登录复用会话)
|
||||||
|
导航到 `https://crm2.yunkecn.com/cms/wechat/wechatlist`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑探测脚本**(参照 spec「交互探测方法」):对第一行每个可点元素(button/a)点击,记录 URL 变化 / modal title / popup,每次点前回列表页、关残留 modal/tab。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 把结果填到本 plan 末尾「微信员工表探测结果」**,据此调整 Task 7/8 的数字→维度映射(微信表的"好友数/消息数/沟通数/及时回复"哪些可点、点出什么 modal)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** 探测结论(更新本 plan)。
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md
|
||||||
|
git commit -m "docs: record wechat staff-list interaction probe results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Customer.add_time 列 + schema + Alembic 迁移
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `yunke-server/app/db/models.py:235`(`Customer`,`last_chat_time` 下方加 `add_time`)
|
||||||
|
- Modify: `yunke-server/app/api/schemas.py`(`CustomerOut`)
|
||||||
|
- Create: `yunke-server/migrations/versions/<rev>_customer_add_time.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 加模型列**(`app/db/models.py`,`Customer` 类内,紧跟 `last_chat_time`):
|
||||||
|
```python
|
||||||
|
last_chat_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
add_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # ms epoch; from raw_json.addTime
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 加 schema 字段**(`app/api/schemas.py`,`CustomerOut`,紧跟 `last_chat_time`):
|
||||||
|
```python
|
||||||
|
last_chat_time: int | None = None
|
||||||
|
add_time: int | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 生成 Alembic 迁移**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && DB_URL="sqlite+aiosqlite:///./_autogen.db" .venv/Scripts/python.exe -m alembic revision --autogenerate -m "customer add_time"
|
||||||
|
```
|
||||||
|
预期:`migrations/versions/<hash>_customer_add_time.py` 生成,内含 `op.add_column('customer', sa.Column('add_time', sa.BigInteger(), nullable=True))`。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 应用迁移到 smoke.db 验证**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m alembic upgrade head
|
||||||
|
```
|
||||||
|
预期:`Running upgrade ... -> <hash>, customer add_time`。验证:`sqlite3 smoke.db "PRAGMA table_info(customer)"` 含 `add_time`。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 跑模型测试确认不破坏**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_models.py tests/test_api_customer.py -x -q
|
||||||
|
```
|
||||||
|
预期:全 PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add yunke-server/app/db/models.py yunke-server/app/api/schemas.py yunke-server/migrations/versions/
|
||||||
|
git commit -m "feat(db): add Customer.add_time column + migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: scraper 映射 add_time(wechat + whatsapp)
|
||||||
|
|
||||||
|
> wechat `addTime` 实测为字符串 `"YYYY-MM-DD HH:mm:ss"`(见 smoke.db);whatsapp `lastUpdate` 已用 `parse_datetime_to_ms`。`add_time` 要支持字符串与 epoch 两种输入。复用 `parse_datetime_to_ms`(`app/scraper/whatsapp.py` 已定义;若 wechat.py 未 import,提到 `app/scraper/_datetime.py` 共享或直接 import)。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `yunke-server/app/scraper/wechat.py`(customer 映射函数,~line 164-192)
|
||||||
|
- Modify: `yunke-server/app/scraper/whatsapp.py`(customer 映射函数,~line 252-264)
|
||||||
|
- Test: `yunke-server/tests/test_scrape_wechat_friends.py`, `test_scrape_whatsapp_friends.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**(`tests/test_scrape_wechat_friends.py`,加一个 case)。
|
||||||
|
函数名是 `_normalize`(`app/scraper/wechat.py:157`,**不是** `_normalize_friend`):
|
||||||
|
```python
|
||||||
|
from app.scraper.wechat import _normalize
|
||||||
|
|
||||||
|
def test_normalize_maps_add_time_string_to_epoch():
|
||||||
|
record = {"friendWxId": "x", "addTime": "2026-04-28 16:24:39", "lastChatTime": 1700000000000}
|
||||||
|
out = _normalize(record)
|
||||||
|
# parse_datetime_to_ms 按 naive UTC 解析;断言是合理的近期 epoch,不写死值(避免 tz/算术陷阱)。
|
||||||
|
# 2026 年任意时刻 > 1.76e12。
|
||||||
|
assert isinstance(out["add_time"], int) and out["add_time"] > 1_760_000_000_000
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测试确认 FAIL**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wechat_friends.py::test_normalize_friend_maps_add_time_string_to_epoch -x -q
|
||||||
|
```
|
||||||
|
预期:KeyError 或 None(add_time 未映射)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: wechat 映射加 add_time**(`app/scraper/wechat.py`,customer 映射 return dict 内,紧跟 `last_chat_time`):
|
||||||
|
```python
|
||||||
|
"last_chat_time": record.get("lastChatTime"),
|
||||||
|
"add_time": _parse_add_time(record.get("addTime")),
|
||||||
|
```
|
||||||
|
在文件 helper 区加(处理字符串+epoch)。`parse_datetime_to_ms` 实际定义在 `app/scraper/parsers.py`(`whatsapp.py` 仅 re-import),按 **naive UTC** 解析:
|
||||||
|
```python
|
||||||
|
from app.scraper.parsers import parse_datetime_to_ms
|
||||||
|
|
||||||
|
_ADD_TIME_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
def _parse_add_time(v):
|
||||||
|
"""addTime: ms-epoch int 或 'YYYY-MM-DD HH:mm[:ss]' 字符串 → ms epoch (naive UTC)。"""
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return int(v)
|
||||||
|
return parse_datetime_to_ms(v, _ADD_TIME_FORMATS)
|
||||||
|
```
|
||||||
|
> 先读 `app/scraper/parsers.py` 的 `parse_datetime_to_ms` 确认它接受 format tuple 且返回 ms epoch(naive UTC)。若签名不同,改为内联:`int(dt.datetime.strptime(v, fmt).replace(tzinfo=dt.UTC).timestamp() * 1000)`(遍历 `_ADD_TIME_FORMATS` 容错)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 跑 wechat 测试确认 PASS**。然后处理 whatsapp(`app/scraper/whatsapp.py` 的 `_normalize_whatsapp_friend`,~line 252)。
|
||||||
|
|
||||||
|
**先 dump 确认 WA customer 有无 customer-side add-time 字段**:
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -c "import sqlite3,json; c=sqlite3.connect('smoke.db'); r=c.execute(\"SELECT raw_json FROM customer WHERE channel='whatsapp' LIMIT 1\").fetchone(); print(list(json.loads(r[0]).keys()) if r else 'none')"
|
||||||
|
```
|
||||||
|
⚠️ 关键:whatsapp `raw_json` 里的 `createTime` 是 **owner-staff** 字段(见 `_normalize_whatsapp_friend` docstring line 246-251),**不是** customer 的添加时间。若 dump 确认 WA customer 无独立的 add-time 字段,则 **WA 的 add_time 留 None**,不要强行用 createTime(会语义错误)。仅 wechat add_time 可靠填充;WA"添加人数"modal 届时按 wechat 路径或降级处理。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 跑全部 scraper 测试**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_scrape_wechat_friends.py tests/test_scrape_whatsapp_friends.py -x -q
|
||||||
|
```
|
||||||
|
预期:PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add yunke-server/app/scraper/ yunke-server/tests/test_scrape_wechat_friends.py yunke-server/tests/test_scrape_whatsapp_friends.py
|
||||||
|
git commit -m "feat(scraper): map customer add_time from raw_json"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: add_time 回填脚本(历史数据)
|
||||||
|
|
||||||
|
> 解析现有 customer 行的 `raw_json.addTime` → `add_time` 列。不重抓(区别于 `backfill_customers.py`)。参照其 async sessionmaker + batch commit 模式。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yunke-server/scripts/backfill_customer_add_time.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写脚本**(参照 `scripts/backfill_customers.py` 的 engine/sessionmaker 设置):
|
||||||
|
```python
|
||||||
|
"""Backfill Customer.add_time from raw_json.addTime (no re-scrape).
|
||||||
|
|
||||||
|
Iterates existing customer rows, parses raw_json.addTime (str or epoch) →
|
||||||
|
add_time ms-epoch, updates in batches. Idempotent: skips rows already set.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.db import models # noqa: F401
|
||||||
|
from app.db.models import Customer
|
||||||
|
from app.scraper.wechat import _parse_add_time # reuse the parser
|
||||||
|
|
||||||
|
BATCH = 200
|
||||||
|
|
||||||
|
|
||||||
|
async def main(db_url: str = "sqlite+aiosqlite:///./smoke.db") -> None:
|
||||||
|
engine = create_async_engine(db_url)
|
||||||
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
done = 0
|
||||||
|
async with sm() as s:
|
||||||
|
rows = (await s.execute(select(Customer.friend_id, Customer.raw_json, Customer.add_time))).all()
|
||||||
|
for i, (fid, raw, existing) in enumerate(rows):
|
||||||
|
if existing or not raw:
|
||||||
|
continue
|
||||||
|
val = _parse_add_time(raw.get("addTime"))
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
await s.execute(update(Customer).where(Customer.friend_id == fid).values(add_time=val))
|
||||||
|
done += 1
|
||||||
|
if done % BATCH == 0:
|
||||||
|
await s.commit()
|
||||||
|
print(f"[{time.strftime('%H:%M:%S')}] {done} updated...")
|
||||||
|
await s.commit()
|
||||||
|
print(f"DONE — {done} customers got add_time")
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import time
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
> 补 `import time` 到顶部(plan 内为可读性放底部,实际放顶部)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑回填(smoke.db)**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m scripts.backfill_customer_add_time
|
||||||
|
```
|
||||||
|
预期:`DONE — N customers got add_time`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证** —— query smoke.db 确认 add_time 非空:
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -c "import sqlite3;c=sqlite3.connect('smoke.db');print('with add_time:', c.execute('SELECT COUNT(*) FROM customer WHERE add_time IS NOT NULL').fetchone()[0]); print('total:', c.execute('SELECT COUNT(*) FROM customer').fetchone()[0])"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add yunke-server/scripts/backfill_customer_add_time.py
|
||||||
|
git commit -m "feat(scripts): backfill Customer.add_time from raw_json"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: customer router 加 user_id / add_time_since / last_chat_since(TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `yunke-server/app/api/routers/customer.py`(`list_customers` 签名 + conditions)
|
||||||
|
- Test: `yunke-server/tests/test_api_customer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**(`tests/test_api_customer.py`)。
|
||||||
|
⚠️ **不要扩展共享 `client` fixture**——它内联 seed 3 行,且 4 个现有测试断言 `total == 3`(`test_list_default_page_returns_all` 等),加行会破坏它们。**新建独立 fixture**(参照 `tests/test_api_customer.py:47-93` 的导入与 app/client 构造方式,对齐 `conftest.py` 的 `async_engine`):
|
||||||
|
```python
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def filter_client(async_engine):
|
||||||
|
async with async_engine.connect() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
Session = async_sessionmaker(async_engine, expire_on_commit=False)
|
||||||
|
async with Session() as s:
|
||||||
|
s.add_all([
|
||||||
|
Customer(friend_id="u1_a", channel="wechat", user_id="U1", add_time=1000, last_chat_time=500),
|
||||||
|
Customer(friend_id="u1_b", channel="wechat", user_id="U1", add_time=2000, last_chat_time=2000),
|
||||||
|
Customer(friend_id="u2_a", channel="wechat", user_id="U2", add_time=3000, last_chat_time=3000),
|
||||||
|
])
|
||||||
|
await s.commit()
|
||||||
|
async with AsyncClient(app=app, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
async def test_filter_by_user_id(filter_client):
|
||||||
|
r = await filter_client.get("/api/customers", params={"user_id": "U1"})
|
||||||
|
assert {c["friend_id"] for c in r.json()["items"]} == {"u1_a", "u1_b"}
|
||||||
|
|
||||||
|
async def test_filter_by_add_time_since(filter_client):
|
||||||
|
r = await filter_client.get("/api/customers", params={"add_time_since": 1500})
|
||||||
|
ids = [c["friend_id"] for c in r.json()["items"]]
|
||||||
|
assert "u1_a" not in ids and "u1_b" in ids and "u2_a" in ids
|
||||||
|
|
||||||
|
async def test_filter_by_last_chat_since(filter_client):
|
||||||
|
r = await filter_client.get("/api/customers", params={"last_chat_since": 1500})
|
||||||
|
assert "u1_a" not in [c["friend_id"] for c in r.json()["items"]]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测试确认 FAIL**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_customer.py -k "user_id or add_time_since or last_chat_since" -x -q
|
||||||
|
```
|
||||||
|
预期:FAIL(参数未识别 / 无筛选)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 实现筛选**(`app/api/routers/customer.py`):
|
||||||
|
签名加:
|
||||||
|
```python
|
||||||
|
user_id: Annotated[str | None, Query()] = None,
|
||||||
|
add_time_since: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
last_chat_since: Annotated[int | None, Query(ge=0)] = None,
|
||||||
|
```
|
||||||
|
conditions 加:
|
||||||
|
```python
|
||||||
|
if user_id is not None:
|
||||||
|
conditions.append(Customer.user_id == user_id)
|
||||||
|
if add_time_since is not None:
|
||||||
|
conditions.append(Customer.add_time >= add_time_since)
|
||||||
|
if last_chat_since is not None:
|
||||||
|
conditions.append(Customer.last_chat_time >= last_chat_since)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 跑测试确认 PASS**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_customer.py -x -q
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 全量回归**
|
||||||
|
```bash
|
||||||
|
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q
|
||||||
|
```
|
||||||
|
预期:全 PASS(≥323)。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add yunke-server/app/api/routers/customer.py yunke-server/tests/test_api_customer.py
|
||||||
|
git commit -m "feat(api): customer filters user_id / add_time_since / last_chat_since"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 前端 api/types 加 customer 参数与字段
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `yunkes/src/api/types.ts`(`Customer`)
|
||||||
|
- Modify: `yunkes/src/api/index.ts`(`CustomerQuery` + `getCustomers`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: `types.ts` `Customer` 加字段**(紧跟 `last_chat_time`):
|
||||||
|
```ts
|
||||||
|
last_chat_time?: number | null
|
||||||
|
/** ms epoch of friend add time (from raw_json.addTime). */
|
||||||
|
add_time?: number | null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `api/index.ts` `CustomerQuery` 加三参数 + `getCustomers` 透传**:
|
||||||
|
```ts
|
||||||
|
export interface CustomerQuery {
|
||||||
|
channel?: Channel | 'all'
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
owner_account_id?: string
|
||||||
|
user_id?: string
|
||||||
|
department_id?: string
|
||||||
|
add_time_since?: number
|
||||||
|
last_chat_since?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomers(query: CustomerQuery = {}): Promise<Page<Customer>> {
|
||||||
|
return getJson<Page<Customer>>(
|
||||||
|
'/customers',
|
||||||
|
clean({
|
||||||
|
page: query.page ?? 1,
|
||||||
|
page_size: query.page_size ?? 20,
|
||||||
|
channel: query.channel === 'all' ? undefined : query.channel,
|
||||||
|
owner_account_id: query.owner_account_id,
|
||||||
|
user_id: query.user_id,
|
||||||
|
department_id: query.department_id,
|
||||||
|
add_time_since: query.add_time_since,
|
||||||
|
last_chat_since: query.last_chat_since,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 类型检查**
|
||||||
|
```bash
|
||||||
|
cd yunkes && npx vue-tsc -b
|
||||||
|
```
|
||||||
|
预期:exit 0。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add yunkes/src/api/types.ts yunkes/src/api/index.ts
|
||||||
|
git commit -m "feat(api): customer query params user_id/add_time_since/last_chat_since"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: YkStaffDetailModal 通用组件
|
||||||
|
|
||||||
|
> 云客"统计数字→明细 modal"的通用复用件。Props:标题、API 函数、查询参数、列定义。内部 `el-dialog` + `el-table` + `el-pagination`。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `yunkes/src/components/YkStaffDetailModal.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写组件**:
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import type { Page } from '@/api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用明细 modal —— 复刻云客"统计数字点击弹明细表"。
|
||||||
|
* 调用方传 fetcher(返回 Page<T>)+ 列定义;组件负责 dialog/table/pagination。
|
||||||
|
*/
|
||||||
|
interface Col {
|
||||||
|
prop: string
|
||||||
|
label: string
|
||||||
|
width?: number | string
|
||||||
|
minWidth?: number | string
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
}
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title: string
|
||||||
|
cols: Col[]
|
||||||
|
/** fetcher(page, page_size) → Page<T>。宽松 Page<any>:Customer 无 index signature,
|
||||||
|
* 不可直接赋 Page<Record<string,unknown>>,故用 any 接纳 Page<Customer> 等。 */
|
||||||
|
fetcher: (page: number, page_size: number) => Promise<Page<any>>
|
||||||
|
pageSizes?: number[]
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const items = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(props.pageSizes?.[0] ?? 10)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await props.fetcher(page.value, pageSize.value)
|
||||||
|
items.value = res.items
|
||||||
|
total.value = res.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
:title="title"
|
||||||
|
width="900px"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<el-table v-loading="loading" :data="items" border style="width: 100%" size="small">
|
||||||
|
<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"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
</el-table>
|
||||||
|
<div style="margin-top: 10px; display: flex; justify-content: flex-end">
|
||||||
|
<el-pagination
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="pageSizes ?? [10, 20, 50]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
background
|
||||||
|
size="small"
|
||||||
|
@current-change="(p: number) => { page = p; load() }"
|
||||||
|
@size-change="(s: number) => { pageSize = s; page = 1; load() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 类型检查**
|
||||||
|
```bash
|
||||||
|
cd yunkes && npx vue-tsc -b
|
||||||
|
```
|
||||||
|
预期:exit 0。
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
git add yunkes/src/components/YkStaffDetailModal.vue
|
||||||
|
git commit -m "feat(ui): YkStaffDetailModal shared detail-modal component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: StaffList WhatsApp 数字→modal(添加人数 / 沟通人数)
|
||||||
|
|
||||||
|
> 云客 WhatsApp 员工表:点「添加人数」→ modal 列 `序号/员工信息/员工WhatsApp/客户WhatsApp/添加时间/最后沟通时间`;点「沟通人数」→ 类似(沟通客户)。我们用 customer 端点 + `user_id` + `add_time_since`/`last_chat_since` 筛选近似。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `yunkes/src/views/StaffList.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: import 组件 + getCustomers**
|
||||||
|
StaffList `<script setup>` 顶部加:
|
||||||
|
```ts
|
||||||
|
import { getCustomers } from '@/api'
|
||||||
|
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
|
||||||
|
```
|
||||||
|
加 modal 状态 + fetcher(script 内):
|
||||||
|
```ts
|
||||||
|
// ---- detail modal (stat-number click → 明细) ----
|
||||||
|
const detailOpen = ref(false)
|
||||||
|
const detailTitle = ref('')
|
||||||
|
const detailFetcher = ref<(p: number, s: number) => Promise<any>>(async () => ({ items: [], total: 0 }))
|
||||||
|
const detailCols = ref<any[]>([])
|
||||||
|
|
||||||
|
const baseCustomerCols = [
|
||||||
|
{ prop: 'friend_nickname', label: '客户', minWidth: 140 },
|
||||||
|
{ prop: 'phone', label: '客户号码', width: 130 },
|
||||||
|
{ prop: 'add_time', label: '添加时间', width: 150 },
|
||||||
|
{ prop: 'last_chat_time', label: '最后沟通', width: 150 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function openAddedModal(row: Account) {
|
||||||
|
// 添加人数 = 该员工最近新增好友(add_time 近 N 天;N 待 Task 0 确认,默认 7)
|
||||||
|
const since = Date.now() - 7 * 86400000
|
||||||
|
detailTitle.value = `添加人数 · ${row.user_name ?? row.account_id}`
|
||||||
|
detailCols.value = baseCustomerCols
|
||||||
|
detailFetcher.value = (p, s) =>
|
||||||
|
getCustomers({ user_id: row.user_id!, channel: 'whatsapp', add_time_since: since, page: p, page_size: s })
|
||||||
|
detailOpen.value = true
|
||||||
|
}
|
||||||
|
function openTalkedModal(row: Account) {
|
||||||
|
const since = Date.now() - 7 * 86400000
|
||||||
|
detailTitle.value = `沟通人数 · ${row.user_name ?? row.account_id}`
|
||||||
|
detailCols.value = baseCustomerCols
|
||||||
|
detailFetcher.value = (p, s) =>
|
||||||
|
getCustomers({ user_id: row.user_id!, channel: 'whatsapp', last_chat_since: since, page: p, page_size: s })
|
||||||
|
detailOpen.value = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> `Date.now()` 在 StaffList 浏览器运行时可用(非 workflow 脚本)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 模板——把 WhatsApp 的「添加人数」「沟通人数」列从纯数字改为可点 link**
|
||||||
|
找到 WhatsApp tab 的:
|
||||||
|
```html
|
||||||
|
<el-table-column label="添加人数" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ cellNum(row, 'friendAddNum') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
```
|
||||||
|
改为:
|
||||||
|
```html
|
||||||
|
<el-table-column label="添加人数" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click.stop="openAddedModal(row)">
|
||||||
|
{{ cellNum(row, 'friendAddNum') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
```
|
||||||
|
「沟通人数」同理用 `openTalkedModal`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 模板末尾挂 modal**(在 `</el-tabs>` 后、`yk-staff__hint` 前):
|
||||||
|
```html
|
||||||
|
<YkStaffDetailModal v-model="detailOpen" :title="detailTitle" :cols="detailCols" :fetcher="detailFetcher" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 类型检查 + 浏览器验证**
|
||||||
|
```bash
|
||||||
|
cd yunkes && npx vue-tsc -b
|
||||||
|
```
|
||||||
|
浏览器打开 `http://localhost:5173/staff` → WhatsApp tab → 点「添加人数」数字 → 确认弹 modal 且有数据。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add yunkes/src/views/StaffList.vue
|
||||||
|
git commit -m "feat(staff): WhatsApp stat-number click → detail modal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: StaffList 微信数字→modal
|
||||||
|
|
||||||
|
> 基于 Task 0 探测结果。微信员工表的「好友数/消息数/沟通数/及时回复」哪些可点、点出什么,按探测结论接入(复用 `openAddedModal`/`openTalkedModal` 模式 + 调整维度)。
|
||||||
|
|
||||||
|
**Files:** Modify `yunkes/src/views/StaffList.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 据探测结果**(本 plan 末尾「微信员工表探测结果」),为微信 tab 的可点数字加 `@click.stop="openXxxModal(row)"`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 浏览器验证微信 tab** 各数字点击 → modal 正确。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 类型检查 + Commit**
|
||||||
|
```bash
|
||||||
|
cd yunkes && npx vue-tsc -b
|
||||||
|
git add yunkes/src/views/StaffList.vue
|
||||||
|
git commit -m "feat(staff): wechat stat-number click → detail modal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 操作按钮跳转 + CustomerList/GroupList 读 query(含后端 groups `user_id` TDD)
|
||||||
|
|
||||||
|
> ⚠️ 本 task 含一个**后端 TDD 子任务**(Step 1:groups router 加 `user_id`,独立于前端改动,等同 Task 4 的规模,不要跳过测试)。
|
||||||
|
|
||||||
|
> 云客「好友列表」→ 该员工客户列表;「群聊」→ 该员工群列表。我们 `router.push('/customers') + setChannel + ?user_id=`;目标页读 query 预筛。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `yunkes/src/views/StaffList.vue`(已有 `viewCustomers`/`viewGroups`,改为带 query + 按 row)
|
||||||
|
- Modify: `yunkes/src/views/CustomerList.vue`(读 `route.query.user_id`)
|
||||||
|
- Modify: `yunkes/src/views/GroupList.vue`(读 `route.query.user_id`,调 `/api/groups` 待加 `user_id`——见下注)
|
||||||
|
|
||||||
|
> **注**:groups router 加 `user_id` 不在本 plan(spec 列了但属通用基础,可在此顺带加,参照 Task 4 模式:`ChatGroup.owner_user_id == user_id`)。本 task 顺带加。
|
||||||
|
|
||||||
|
- [ ] **Step 1: groups router 加 user_id**(`app/api/routers/groups.py`,参照 customer.py):
|
||||||
|
签名加 `user_id: Annotated[str | None, Query()] = None`,conditions 加 `if user_id is not None: conditions.append(ChatGroup.owner_user_id == user_id)`。加测试 `tests/test_api_groups.py::test_filter_by_user_id`。跑测试 PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 前端 GroupQuery + getGroups 加 user_id**(`api/index.ts`)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: StaffList `viewCustomers`/`viewGroups` 改为按 row + 带 query**:
|
||||||
|
```ts
|
||||||
|
function viewCustomers(row: Account) {
|
||||||
|
channelStore.setChannel('whatsapp')
|
||||||
|
router.push({ path: '/customers', query: row.user_id ? { user_id: row.user_id } : {} })
|
||||||
|
}
|
||||||
|
function viewGroups(row: Account) {
|
||||||
|
channelStore.setChannel('whatsapp')
|
||||||
|
router.push({ path: '/groups', query: row.user_id ? { user_id: row.user_id } : {} })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
模板操作列改为 `@click.stop="viewCustomers(row)"` / `viewGroups(row)"`。
|
||||||
|
|
||||||
|
- [ ] **Step 4: CustomerList 读 user_id query**(参照它已读 `department_id` 的模式,~line 33):
|
||||||
|
```ts
|
||||||
|
const userId = route.query.user_id ? String(route.query.user_id) : null
|
||||||
|
```
|
||||||
|
`getCustomers` 调用加 `user_id: userId`;watch 响应 `userId` 变化;顶部加 chip + 清除(清除时 `router.replace({ query: {} })`)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: GroupList 同理读 user_id query**。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 浏览器验证** —— StaffList WhatsApp → 点「好友列表」→ 跳客户列表,只显示该员工客户 + 顶部 chip;点「群聊」同理。
|
||||||
|
|
||||||
|
- [ ] **Step 7: 类型检查 + 全后端回归 + Commit**
|
||||||
|
```bash
|
||||||
|
cd yunkes && npx vue-tsc -b
|
||||||
|
cd ../yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q
|
||||||
|
git add ...
|
||||||
|
git commit -m "feat(staff): action buttons → filtered customer/group list"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 昵称→聊天(ChatTimeline)
|
||||||
|
|
||||||
|
> 云客点 WA 昵称开聊天记录页(会话列表+消息流)。我们 ChatTimeline 当前是消息流,需具体 talkerId。点员工昵称跳"该账号聊天"——暂降级为跳 ChatTimeline 用该账号最近一条会话(spec 风险章节已记)。
|
||||||
|
|
||||||
|
**Files:** Modify `yunkes/src/views/StaffList.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 加 `viewChat` 函数**(取该 owner 最近一条 message 的 talker_id,跳 ChatTimeline):
|
||||||
|
```ts
|
||||||
|
import { getMessages } from '@/api'
|
||||||
|
async function viewChat(row: Account) {
|
||||||
|
// 最近一条会话的 talker
|
||||||
|
const res = await getMessages({ channel: row.channel, owner_account_id: row.account_id, page: 1, page_size: 1 })
|
||||||
|
const m = res.items[0]
|
||||||
|
if (!m) { ElMessage.info('该账号暂无聊天记录'); return }
|
||||||
|
const path = m.room_id
|
||||||
|
? `/group/${row.channel}/${row.account_id}/${m.room_id}`
|
||||||
|
: `/chat/${row.channel}/${row.account_id}/${m.talker_id}`
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 模板——WhatsApp「员工WhatsApp」列昵称可点**:
|
||||||
|
找到 `<span class="yk-staffinfo__nick" :title=...">{{ cellText(row, 'nickName') }}</span>`,包一层 `@click.stop="viewChat(row)"` + `class="yk-link"`,加 `.yk-link { color: var(--el-color-primary); cursor: pointer; }` 样式。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 浏览器验证** —— 点 WhatsApp 昵称 → 跳 ChatTimeline 该账号聊天。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 类型检查 + Commit**
|
||||||
|
```bash
|
||||||
|
cd yunkes && npx vue-tsc -b
|
||||||
|
git add yunkes/src/views/StaffList.vue
|
||||||
|
git commit -m "feat(staff): WhatsApp nickname → chat timeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 收尾验证
|
||||||
|
|
||||||
|
- [ ] 全后端测试绿:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -q`(≥323)
|
||||||
|
- [ ] 前端类型:`cd yunkes && npx vue-tsc -b` exit 0
|
||||||
|
- [ ] 浏览器 `http://localhost:5173/staff`:微信+WhatsApp 两 tab,数字点击、操作跳转、昵称跳聊天全部对照云客验证,截图留档。
|
||||||
|
- [ ] `/api/customers?user_id=X&add_time_since=Y` 返回正确筛选项(curl 验证)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 微信员工表探测结果(Task 0 填写)
|
||||||
|
|
||||||
|
> 待 Task 0 执行后填写:每个可点数字 → modal 标题 + 列 + 对应后端筛选维度。
|
||||||
|
|
||||||
|
- 好友数(1229):____ → modal? 列? 筛选维度?
|
||||||
|
- 消息数:____
|
||||||
|
- 沟通数:____
|
||||||
|
- 及时回复:____
|
||||||
|
- 员工信息列内元素:____
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续 Plan(不在本 plan 范围)
|
||||||
|
|
||||||
|
- Plan 2: CustomerList 列+交互对齐(phase 0 采集后写)
|
||||||
|
- Plan 3: GroupList / CallList / SmsList
|
||||||
|
- Plan 4: ChatTimeline 会话列表 + `/api/sessions` 聚合端点
|
||||||
|
- Plan 5: SearchView + AccountList 删除
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# yunkes 前端页面对齐云客后台 — 设计文档
|
||||||
|
|
||||||
|
- 日期:2026-08-05
|
||||||
|
- 分支:feat/yunke-data-mirror
|
||||||
|
- 状态:待评审
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
yunkes 是云客 CRM(crm2.yunkecn.com)数据镜像的查看后台(Vue3 + Element Plus)。当前各列表/详情页的表格列结构与交互均与云客后台(Ant Design Vue)不一致,用户在两套系统间切换认知成本高。
|
||||||
|
|
||||||
|
需将所有"有云客对应"的页面**列结构 + 交互功能**对齐云客。StaffList(员工列表)已于 2026-08-05 完成列结构对齐(待补交互),验证了采集与改造方法。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
所有有云客对应的列表/详情页:
|
||||||
|
1. **列结构**(列名、顺序、操作按钮、复合单元格字段组合)与云客一致
|
||||||
|
2. **交互功能**(可点元素 + 点击行为:跳转/modal/新 tab)与云客一致
|
||||||
|
|
||||||
|
视觉用 Element Plus 自然样式(不引入 antd,不做像素级复刻)。包含必要的后端筛选参数扩展。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
### 对齐(6 个页面 + StaffList 补交互)
|
||||||
|
| 页面 | 路由 | 云客对应 | 列对齐 | 交互 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| StaffList | `/staff` | 员工微信列表 + WhatsApp 员工列表 | ✅ 已完成 | 本轮补 |
|
||||||
|
| CustomerList | `/customers` | 客户列表 | 待做 | 待做 |
|
||||||
|
| GroupList | `/groups` | 群列表/统计 | 待做 | 待做 |
|
||||||
|
| CallList | `/calls` | 语音通话列表 | 待做 | 待做 |
|
||||||
|
| SmsList | `/sms` | 短信记录 | 待做 | 待做 |
|
||||||
|
| ChatTimeline | `/chat`,`/group` | 聊天记录(会话列表+消息流,只读查看器) | 待做 | 待做 |
|
||||||
|
| SearchView | `/search` | 全局搜索 | 待做 | 待做 |
|
||||||
|
|
||||||
|
### 删除
|
||||||
|
- **AccountList `/accounts`** — 云客无此页面,且与 StaffList 内容重复。删除路由 + 文件 + 导航入口,`/accounts` → `/staff` 客户端重定向。
|
||||||
|
|
||||||
|
### 不动
|
||||||
|
- SyncStatus `/sync` — 运维监控页,云客无对应。
|
||||||
|
|
||||||
|
## 列对齐设计
|
||||||
|
|
||||||
|
**对齐**:列名、列顺序、操作按钮(文字/位置)、复合单元格字段组合。
|
||||||
|
**不对齐**:antd vs el-table 像素级视觉(配色/行高/表头用 el-table 默认 + 少量 scoped 微调)。
|
||||||
|
|
||||||
|
**方法**(StaffList 已验证):Playwright `browser_evaluate` 采云客表头 + 复合单元格字段(`td.innerText` 按行拆分)+ 操作按钮文字 → dump smoke.db raw_json 核字段 → 改 `.vue`(el-table 列定义,字段经 `readField` 读)→ 浏览器对比 + `vue-tsc`。
|
||||||
|
|
||||||
|
## 交互对齐设计
|
||||||
|
|
||||||
|
云客列表的可点元素分三类,每类统一处理模式(已在 WhatsApp 员工表验证):
|
||||||
|
|
||||||
|
### 类型 1:跳转到列表页(route push)
|
||||||
|
- **触发**:操作按钮(如"好友列表""群聊")、部分可点数字
|
||||||
|
- **行为**:`router.push` 到目标列表页,带 query 预筛(`?owner_account_id=` 或 `?user_id=`)
|
||||||
|
- **目标页改动**:读 `route.query` 的 owner/user 参数 → 传给 API 筛选 + 顶部显示"按员工 X 筛选"chip + 清除按钮
|
||||||
|
- **云客实例**:WhatsApp 员工表"好友列表"→ `friendlist/0/{userId}`,"群聊"→ `friendlist/1/{userId}`
|
||||||
|
- **后端**:`/api/customers`、`/api/groups` 已支持 `owner_account_id` ✓;新增 `user_id` 筛选(云客按 userId)
|
||||||
|
|
||||||
|
### 类型 2:明细 modal(el-dialog)
|
||||||
|
- **触发**:统计数字(如"添加人数""沟通人数""消息数")
|
||||||
|
- **行为**:弹出 `el-dialog`,内嵌精简明细表格,数据按"该员工 + 该维度"筛
|
||||||
|
- **实现**:通用组件 `YkStaffDetailModal`(props:title / api 函数 / columns / 筛选参数),多页复用。复用 `el-table` + `el-pagination`
|
||||||
|
- **云客实例**:WhatsApp 员工表"添加人数"→ modal 列 `序号/员工信息/员工WhatsApp/客户WhatsApp/添加时间/最后沟通时间`
|
||||||
|
- **后端**(customer raw_json 已确认含 `addTime`/`lastChatTime`/`messageCount`):
|
||||||
|
- 添加人数 → `customer where owner=X AND add_time >= now-N天` → 加 `add_time_since` 参数(依赖新增 `Customer.add_time` 列,见"后端端点需求")
|
||||||
|
- 沟通人数 → `customer where owner=X AND lastChatTime >= now-N天` → 加 `last_chat_since` 参数
|
||||||
|
- 其他维度(消息数/好友数等)执行时确认对应查询条件
|
||||||
|
|
||||||
|
### 类型 3:新 tab 聊天
|
||||||
|
- **触发**:昵称 / 账号
|
||||||
|
- **行为**:`router.push`(或新 tab)到 ChatTimeline `/chat/:channel/:ownerId/:talkerId`
|
||||||
|
- **云客实例**:WhatsApp 员工表 WA 昵称 → `wschatroom/...`(该账号聊天记录)
|
||||||
|
- **后端**:`/api/messages` 已支持 `owner_account_id` ✓;点昵称打开的是**会话列表 + 消息流**视图(已验证云客 wschatroom 即此结构,只读、无发消息),需新增 `/api/sessions` 聚合端点(见后端端点需求)
|
||||||
|
|
||||||
|
### 交互探测方法(每页执行时)
|
||||||
|
用 Playwright 探测脚本(已验证):对每行每个可点元素(`button/a`),点击后记录"URL 变化 / modal title / popup",每次点前回到列表页、关闭残留 modal/tab。产出该页"可点元素 → 行为类型 → 目标"映射表。
|
||||||
|
|
||||||
|
## 后端端点需求
|
||||||
|
|
||||||
|
| 端点 | 扩展 | 依据列 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET /api/customers` | 加 `user_id` | `Customer.user_id`(已有列) | 按员工筛客户 |
|
||||||
|
| `GET /api/customers` | 加 `last_chat_since` | `Customer.last_chat_time`(epoch 列) | 沟通人数 modal |
|
||||||
|
| `GET /api/customers` | 加 `add_time_since` | **需新增 `Customer.add_time` 列**(见下) | 添加人数 modal |
|
||||||
|
| `GET /api/groups` | 加 `user_id` | `ChatGroup.owner_user_id`(参数名 `user_id` → 此列,注意映射) | 按员工筛群 |
|
||||||
|
| `GET /api/accounts` | — | 已支持 channel | StaffList 用 |
|
||||||
|
| `GET /api/messages` | — | 已支持 `owner_account_id` | 昵称跳聊天 ✓ |
|
||||||
|
| `GET /api/sessions`(新增) | 按账号列会话:好友(customer)+群聊(group),含今日消息数 + 最近联系时间 | 聚合 customer/group + message(当日计数) | ChatTimeline 会话列表、类型 3 昵称入口 |
|
||||||
|
|
||||||
|
**`user_id` / `last_chat_since`**:直接走现有 router 模式(`Query()` 参数 + `conditions.append`),单测覆盖。
|
||||||
|
|
||||||
|
**`add_time_since` 需要数据模型扩展(本计划唯一 schema 变更)**:`Customer` 当前无 `add_time` 结构化列——`addTime` 仅在 `raw_json` 里且是字符串 `"YYYY-MM-DD HH:mm:ss"`,无法用 `col >= value` 直接筛(JSON-path 查询方言相关且慢,不采用)。方案:新增 `Customer.add_time` 列(BigInteger ms-epoch,对齐 `last_chat_time`)+ Alembic 迁移 + 回填脚本(解析 `raw_json.addTime` 字符串 → epoch)。完成后即可走标准筛选,参照 `last_chat_time` 既有实现与回填方式(项目已有 surrogate 修复回填先例)。
|
||||||
|
|
||||||
|
**同步层适配(关键,易漏)**:增量同步引擎(fetcher/ingest)抓取新 customer 时也必须解析 `raw_json.addTime` 填充 `add_time`,否则回填之后到达的增量数据该字段仍为 null。回填脚本只覆盖历史,同步层覆盖增量,两者都要做。
|
||||||
|
|
||||||
|
## 顺序
|
||||||
|
|
||||||
|
0. **全量采集(plan 第 0 步,必做)**:对每个待做页面,用 Playwright 采云客的列结构 + 可点元素 + 交互行为,dump 对应 raw_json 核字段,产出每页"目标列 + 交互映射 + 字段覆盖"清单。把当前的外推假设变成事实,避免返工。当前仅 WhatsApp 员工表 + 聊天页结构是已采事实,其余为外推。
|
||||||
|
1. **StaffList 补交互**(已采,先闭环):数字 modal + 操作跳转 + 昵称聊天 + 后端 customer 筛选参数
|
||||||
|
2. **CustomerList**:列对齐 + 可点元素交互(预计含跳客户详情、消息数 modal 等)
|
||||||
|
3. **GroupList**:列对齐 + 交互 + 后端 group user_id
|
||||||
|
4. **CallList + SmsList**(并行):列对齐 + 交互
|
||||||
|
5. **ChatTimeline**:对齐云客只读聊天查看器——左侧会话列表(好友/群聊 tab + 今日消息数 + 最近联系时间)+ 右侧消息流 + 查询/导出按钮;新增 `/api/sessions` 聚合端点(工作量较大,单独评估)
|
||||||
|
6. **SearchView**:搜索结果布局对齐 + 交互
|
||||||
|
7. **AccountList 删除**(收尾)
|
||||||
|
|
||||||
|
## 共性策略
|
||||||
|
|
||||||
|
- 沿用统一模式:`useChannelStore`、rtl 全字段抽屉(`build*Sections`)、`[20/50/100]` 分页、`row-click` 开抽屉。
|
||||||
|
- 字段读取复用各 `utils/*Fields.ts` 的 `readField`;新字段补进 `*_FIELD_LABELS`/`*_FIELD_GROUPS`。
|
||||||
|
- **字符串时间修复**:云客 `addTime`/`lastUpdate` 等是 `"YYYY-MM-DD HH:mm:ss"`,旧代码按 number 解析显示 `--`;统一用 `cellTimeText`(字符串直显、number 才 formatTime)。StaffList 已修,其他页一并修。
|
||||||
|
- **明细 modal 抽通用组件**(`YkStaffDetailModal`)——多页多数字复用,值得抽(不违反 YAGNI)。
|
||||||
|
- CallList 的 `channel` 是 `phone_call`/`voice_call`(通话类型),保留其 LOCAL `callType` 筛选。
|
||||||
|
- 不引入云客有但后端不支持的功能(导出/查询等装饰按钮),除非单独要求。
|
||||||
|
|
||||||
|
## AccountList 删除清单
|
||||||
|
|
||||||
|
- `router/index.ts`:移除 `/accounts` 路由记录,新增 `/accounts` → `/staff` 重定向。
|
||||||
|
- 删除 `views/AccountList.vue`。
|
||||||
|
- 清理导航入口(`layouts/MainLayout.vue` 约 line 126 指向 `/accounts` 的菜单项)。
|
||||||
|
- 更新 `StaffList.vue` 两处对 `AccountList.vue` 的注释引用(drawer 实际来自 `accountFields.ts`)。
|
||||||
|
- **保留数据层**:后端 `GET /api/accounts`(yunke-server)与前端 `getAccounts()`(`api/index.ts`,StaffList 依赖)是数据接口,不删。
|
||||||
|
- 全仓 grep `AccountList`、`/accounts` 确认无 UI 残留引用。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 每页列名/顺序/操作按钮与云客一致(截图留档)。
|
||||||
|
- 每页可点元素的行为与云客一致(跳转目标 / modal 列 / 新 tab),探测对比留档。
|
||||||
|
- 数据正确填充(raw_json 读字段,无整列 `--`)。
|
||||||
|
- 字符串时间字段正确显示。
|
||||||
|
- 后端新筛选参数有单测;`Customer.add_time` 迁移 + 回填 + **同步引擎填充**完成;`/api/sessions` 聚合端点有单测;`pytest` 全绿(≥323 测试)。
|
||||||
|
- `vue-tsc -b` exit 0,无 console 报错。
|
||||||
|
- `/accounts` 客户端重定向到 `/staff`(URL 变 `/staff`、StaffList 渲染;非 HTTP 301)。
|
||||||
|
|
||||||
|
## 风险与降级
|
||||||
|
|
||||||
|
- **字段缺口**:某页云客 KPI 字段不在 raw_json → dump 确认;缺失显示 `--`/`0`,不阻塞,记入附录。
|
||||||
|
- **modal 维度查询不明**:某些数字(如"消息数")对应的后端筛选条件云客未必直白 → 以云客 modal 实际返回数据为准,反推查询;无法精确复刻的,降级为跳转到对应列表页。
|
||||||
|
- **会话列表视图(已验证需要)**:云客聊天页 = 会话列表 + 消息流(只读、无发消息),需新增 `/api/sessions` 聚合端点(好友/群聊 + 今日消息数 + 最近联系时间)。今日消息数需按 message 当日计数聚合,注意大数据量下的查询性能。
|
||||||
|
- **modal 统计窗口**:员工列表统计已验证**不依赖日期选择器**(顶部 pickerCount=0,固定统计),但"添加人数 N"的窗口语义(最近几天/全部)仍未确认 → 执行时从 modal 明细的 addTime 范围反推;无法确认时默认取全部或最近 7 天并标注近似。
|
||||||
|
- **采集时效**:云客改版则列/交互会变 → 以采集时刻为准,spec 记录采集日期(2026-08-05)。
|
||||||
|
- **工作量**:列+交互+后端是大工程 → plan 按"页面 × 阶段"分解,每页可独立交付,不阻塞整体。
|
||||||
Reference in New Issue
Block a user