Files
yunke/docs/superpowers/plans/2026-08-05-yunke-stafflist-interaction.md
T
浅念 1d815d84b9 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>
2026-08-05 18:30:12 +08:00

30 KiB
Raw Blame History

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)。

    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):

        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):

        last_chat_time: int | None = None
        add_time: int | None = None
    
  • Step 3: 生成 Alembic 迁移

    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 验证

    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: 跑模型测试确认不破坏

    cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_models.py tests/test_api_customer.py -x -q
    

    预期:全 PASS。

  • Step 6: Commit

    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):

    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

    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):

        "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 解析:

    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 字段:

    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 测试

    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

    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 设置):

    """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)

    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 非空:

    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

    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):

    @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

    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): 签名加:

        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 加:

        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

    cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_customer.py -x -q
    
  • Step 5: 全量回归

    cd yunke-server && .venv/Scripts/python.exe -m pytest tests/ -x -q
    

    预期:全 PASS(≥323)。

  • Step 6: Commit

    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):

      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 透传:

    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: 类型检查

    cd yunkes && npx vue-tsc -b
    

    预期:exit 0。

  • Step 4: Commit

    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: 写组件:

    <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: 类型检查

    cd yunkes && npx vue-tsc -b
    

    预期:exit 0。

  • Step 3: Commit

    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> 顶部加:

    import { getCustomers } from '@/api'
    import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
    

    加 modal 状态 + fetcher(script 内):

    // ---- 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 的:

    <el-table-column label="添加人数" width="100" align="right">
      <template #default="{ row }">{{ cellNum(row, 'friendAddNum') }}</template>
    </el-table-column>
    

    改为:

    <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 前):

    <YkStaffDetailModal v-model="detailOpen" :title="detailTitle" :cols="detailCols" :fetcher="detailFetcher" />
    
  • Step 4: 类型检查 + 浏览器验证

    cd yunkes && npx vue-tsc -b
    

    浏览器打开 http://localhost:5173/staff → WhatsApp tab → 点「添加人数」数字 → 确认弹 modal 且有数据。

  • Step 5: Commit

    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

    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:

    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):

    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

    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):

    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

    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 删除