9499110427
WeChat interactions are navigation (not modal): 好友数/群数→customer/group list, 昵称→chat. Task 8 redone as navigation; Task 9 uses owner_account_id + row.channel. Co-Authored-By: Claude <noreply@anthropic.com>
741 lines
32 KiB
Markdown
741 lines
32 KiB
Markdown
# 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 9、10 之后执行**(复用 `viewCustomers`/`viewGroups`/`viewChat`)。
|
||
> Task 0 探测结论:微信员工表数字点击是**跳转**(非 modal),与 WhatsApp 不同。微信 tab 全部交互复用 Task 9/10 的跳转函数。
|
||
|
||
**Files:** Modify `yunkes/src/views/StaffList.vue`
|
||
|
||
- [ ] **Step 1: 微信 tab「好友数」列**——把复合单元格里「好友数」的值包成可点 link → `viewCustomers(row)`(`row.channel` 自动是 wechat,owner=wxId=`row.account_id`);「微信群数」值 → `viewGroups(row)`。
|
||
例(在微信 tab「好友数」`el-table-column` 的 `yk-pair` 里):
|
||
```html
|
||
<el-button link type="primary" size="small" @click.stop="viewCustomers(row)">{{ cellNum(row, 'contactsNum') }}</el-button>
|
||
```
|
||
|
||
- [ ] **Step 2: 微信 tab 昵称**(员工信息列 `cellText(row, 'nickName')`)→ `viewChat(row)`(复用 Task 10)。
|
||
|
||
- [ ] **Step 3: 手机号**——云客跳员工详情,我们暂无此页 → **降级**:不接入(标注:降级,后续员工详情页再做)。
|
||
|
||
- [ ] **Step 4: 浏览器验证微信 tab**:点好友数→客户列表(wechat, owner=wxId)、群数→群列表、昵称→聊天。
|
||
|
||
- [ ] **Step 5: 类型检查 + Commit**
|
||
```bash
|
||
cd yunkes && npx vue-tsc -b
|
||
git add yunkes/src/views/StaffList.vue
|
||
git commit -m "feat(staff): wechat stat-number/nickname → navigation"
|
||
```
|
||
|
||
---
|
||
|
||
## 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**。用 `owner_account_id`(= `row.account_id`:微信 wxId / WhatsApp uid 都成立),CustomerList/GroupList **已支持** `owner_account_id` 筛选,无需等 user_id:
|
||
```ts
|
||
function viewCustomers(row: Account) {
|
||
channelStore.setChannel(row.channel)
|
||
router.push({ path: '/customers', query: { owner_account_id: row.account_id } })
|
||
}
|
||
function viewGroups(row: Account) {
|
||
channelStore.setChannel(row.channel)
|
||
router.push({ path: '/groups', query: { owner_account_id: row.account_id } })
|
||
}
|
||
```
|
||
模板(两个 tab 的操作列)改为 `@click.stop="viewCustomers(row)"` / `@click.stop="viewGroups(row)"`。
|
||
|
||
- [ ] **Step 4: CustomerList 读 `owner_account_id` query**(参照它已读 `department_id` 的模式,~line 33):
|
||
```ts
|
||
const ownerId = route.query.owner_account_id ? String(route.query.owner_account_id) : null
|
||
```
|
||
`getCustomers` 调用加 `owner_account_id: ownerId`;watch 响应 `ownerId` 变化;顶部加 chip「筛选自员工」+ 清除(清除时 `router.replace({ query: {} })`)。
|
||
|
||
- [ ] **Step 5: GroupList 同理读 `owner_account_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 已执行,2026-08-05)
|
||
|
||
微信员工表的交互与 WhatsApp **不同**——以**跳转**为主,**无数字→modal**:
|
||
|
||
| 元素 | 行为 | 目标 |
|
||
|---|---|---|
|
||
| 好友数(数字) | 跳转 | `/wechatdetail/1/{wxId}` —— 该微信号客户列表(owner=wxId) |
|
||
| 群数(数字) | 跳转 | `/wechatdetail/2/{wxId}` —— 该微信号群列表 |
|
||
| 昵称 | 新 tab | `salewechat/message/wechat-window/...` 微信聊天记录 |
|
||
| 手机号 | 新 tab | `customer/userDetailNew?userId=...` 员工详情 |
|
||
| 主/副标记 | 无明显效果 | — |
|
||
| 消息数/沟通数/及时回复 | **不可点** | — |
|
||
|
||
**关键差异**:WhatsApp 用 modal(添加人数/沟通人数);微信用跳转(好友数/群数)。故 Task 7(WhatsApp modal)和 Task 8(微信跳转)实现方式不同。
|
||
|
||
---
|
||
|
||
## 后续 Plan(不在本 plan 范围)
|
||
|
||
- Plan 2: CustomerList 列+交互对齐(phase 0 采集后写)
|
||
- Plan 3: GroupList / CallList / SmsList
|
||
- Plan 4: ChatTimeline 会话列表 + `/api/sessions` 聚合端点
|
||
- Plan 5: SearchView + AccountList 删除
|