Files
yunke/docs/superpowers/plans/2026-08-08-yunke-staff-modal-phase2.md
T

246 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 微信员工 stat modal 列对齐 + 客户微信→聊天(Phase B)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.
**Goal:** Phase A 的 5 数字 stat modal 与云客对齐——补 序号/员工信息/员工微信 列,modal 客户微信可点 → 带日期窗的 1:1 聊天;ChatTimeline 读 start/end query 过滤。
**Architecture:** 纯前端 2 文件改。StaffList 的 modal fetcher 在合成分页时给每行注入 `_serial`/`customer_wx`/`staff_info`/`staff_wx` 字段(不改 `YkStaffDetailModal`,其 cols 仍 prop 驱动 + 支持 `onClick`)→ 客户微信 `onClick` 跳 `/chat/wechat/{owner}/{talker}?start=&end=`。ChatTimeline 加 `useRoute` 读 query 透传 `getMessages`(已支持 start/end)。员工姓名→drawer、badge→最近聊天 不动。
**Tech Stack:** Vue3 + TS + Element Plus(`yunkes/`)。类型门禁 `vue-tsc -b`(无 Vue 单测)。无后端、无新表。
**Spec:** `docs/superpowers/specs/2026-08-08-yunke-staff-modal-phase2-design.md`(已过审 + 用户确认)。
---
## 关键事实(plan 核实,来自 codebase)
- `commModalColsFor(metric)` 在 [StaffList.vue:340](../../../yunkes/src/views/StaffList.vue),当前返回 `[{客户微信}]`(+ effective 加发送/接收)。需改为 `(metric, row)` 并返回云客 5~7 列。
- stat modal fetcher 在 `openWechatCommModal`([StaffList.vue:367-397](../../../yunkes/src/views/StaffList.vue)):`filtered.slice(startIdx, startIdx+page_size)` 后 `return {items, total, page, page_size}`。注入字段就在这个 slice 的 `.map` 里做。
- `row.account_id` = 员工 wxid(owner);`StaffChatCustomer.talker_id` = 客户 wxid。`useRouter`/`router` 已在 StaffList(line 3/60)。`cellText(row, camelKey)` helper 已存在。
- `YkStaffDetailModal` `Col` 接口支持 `onClick?: (row)=>void`(渲染为 link button)与 `prop` 驱动默认 cell → 注入字段直接可显。**不改该组件。**
- ChatTimeline `defineProps<{channel,ownerId,talkerId?,roomId?}>`([ChatTimeline.vue:20](../../../yunkes/src/views/ChatTimeline.vue));`queryFor`(72-81)、`loadInitial`(88)、`subtitle`(56-60)、`watch([props...])`(166-171)、`onMounted(loadInitial)`(173)。`getMessages` 已支持 `start/end`(`api/index.ts:65-66`)。
- `/chat/:channel/:ownerId/:talkerId`(name `'chat'`,`router/index.ts:61`,`props:true`)→ query 透传。
- 云客真机(2026-08-08):modal 列 = 序号/客户微信(可点)/[有效沟通:发送·接收]/员工信息/员工微信,无操作列;客户微信→`detail/message/{owner}/{talker}/1?start=&end=`(带窗过滤);员工信息=员工姓名+手机号;员工微信=昵称+wxid。
## 实现决策(spec §4 已定)
1. 客户微信显示 = `friend_name + talker_id`(注入为 `customer_wx` 字段,免 formatter),**不脱敏**。
2. 员工信息 = 员工姓名(`userName`)+ 微信手机号(`phone`);员工微信 = 昵称(`nickName`)+ wxid(`account_id`)——匹配云客 modal(云客员工微信显示 wxid 非 alias)。缺失字段过滤掉,全缺显 `--`。
3. ChatTimeline 仅带窗过滤(从 modal 进时);**页内日期选择器留总 spec 第5步**。`watch` 把 `route.query.start/end` 加入依赖(支持同路径改窗重载,边际稳健)。
4. 同页 `router.push`(不 window.open)。
## 文件清单
- 改 `yunkes/src/views/StaffList.vue`:`commModalColsFor(metric, row)` 列补齐 + `openCustomerChat` + fetcher 注入字段。
- 改 `yunkes/src/views/ChatTimeline.vue`:`useRoute` + `windowStart/End` computed + `queryFor` 透传 + `subtitle` 显窗 + `watch` 加 query 依赖。
- `YkStaffDetailModal.vue` **不改**;无后端。
---
## Task 1: StaffList — modal 列对齐云客 + 客户微信可点
**Files:** Modify `yunkes/src/views/StaffList.vue`(commModalColsFor / openWechatCommModal / 新增 openCustomerChat、joinFields)。
- [ ] **Step 1: 加两个 helper(放在 `commModalColsFor` 之前)**
```typescript
/** 拼接非空字段(过滤 '--'),全缺则 '--'。用于 modal 里员工信息/员工微信 单行显示。 */
function joinFields(...vals: (string | undefined | null)[]): string {
const parts = vals.map((v) => (v == null ? '' : String(v).trim())).filter((v) => v && v !== '--')
return parts.length ? parts.join(' ') : '--'
}
/** modal 客户微信 → 该员工×客户聊天(带 StaffList 当前日期窗)。缺失 owner/talker 静默 no-op。 */
function openCustomerChat(row: Account, customer: StaffChatCustomer): void {
if (!row.account_id || !customer.talker_id) return
router.push({
name: 'chat',
params: { channel: 'wechat', ownerId: row.account_id, talkerId: customer.talker_id },
query: { start: String(statsStart.value), end: String(statsEnd.value) },
})
}
```
> `StaffChatCustomer` 已在 Phase A import;`router`/`statsStart`/`statsEnd`/`cellText` 均已存在。
- [ ] **Step 2: 改 `commModalColsFor` 签名为 `(metric, row)` + 返回云客列序**
把现有 `commModalColsFor(metric)` 整体替换为(注意 `row` 参数 + 客户微信 `onClick` + 末尾员工信息/员工微信):
```typescript
// 对齐云客(2026-08-08 真机):序号 / 客户微信(可点→聊天) / [仅有效沟通:发送·接收] / 员工信息 / 员工微信。
// 序号/员工信息/员工微信 靠 fetcher 注入字段实现,不改 YkStaffDetailModal(其 cols 仍 prop 驱动)。
function commModalColsFor(metric: ChatMetric, row: Account): any[] {
const cols: any[] = [
{ prop: '_serial', label: '序号', width: 70, align: 'center' },
{
prop: 'customer_wx',
label: '客户微信',
minWidth: 180,
onClick: (c: any) => openCustomerChat(row, c),
},
]
if (metric === 'effective') {
cols.push(
{ prop: 'send_count', label: '发送消息数', width: 110, align: 'center' },
{ prop: 'receive_count', label: '接收消息数', width: 110, align: 'center' },
)
}
cols.push(
{ prop: 'staff_info', label: '员工信息', minWidth: 140 },
{ prop: 'staff_wx', label: '员工微信', minWidth: 160 },
)
return cols
}
```
- [ ] **Step 3: `openWechatCommModal` 传 `row` 给 cols + fetcher 注入字段**
`detailCols.value = commModalColsFor(metric)` → `commModalColsFor(metric, row)`。
fetcher 里把
```typescript
return {
items: filtered.slice(startIdx, startIdx + page_size),
total: filtered.length,
page,
page_size,
}
```
替换为(注入 `_serial`(跨页连续)/`customer_wx`/`staff_info`/`staff_wx`):
```typescript
const staffInfo = joinFields(cellText(row, 'userName'), cellText(row, 'phone'))
const staffWx = joinFields(cellText(row, 'nickName'), row.account_id)
const items = filtered.slice(startIdx, startIdx + page_size).map((c, i) => ({
...c,
_serial: startIdx + i + 1,
customer_wx: joinFields(c.friend_name, c.talker_id),
staff_info: staffInfo,
staff_wx: staffWx,
}))
return { items, total: filtered.length, page, page_size }
```
> 注:`staffInfo`/`staffWx` 对整页常量,提到 map 外只算一次。
- [ ] **Step 4: 类型门禁**
```bash
cd yunkes && npx vue-tsc -b
```
Expected: exit 0。常见坑:`commModalColsFor` 旧的无参 `metric` 调用点遗漏(只有 openWechatCommModal 一处);`customer_wx`/`_serial` 等注入字段是 `any` 不报类型。
- [ ] **Step 5: Commit**
```bash
git add yunkes/src/views/StaffList.vue
git commit -m "feat(staff): stat modal 列对齐云客(序号/员工信息/员工微信) + 客户微信可点带窗跳聊天"
```
End with blank line + `Co-Authored-By: Claude <noreply@anthropic.com>`。
---
## Task 2: ChatTimeline — 读 start/end query 过滤
**Files:** Modify `yunkes/src/views/ChatTimeline.vue`。
- [ ] **Step 1: import `useRoute` + 实例化**
line 3:`import { useRouter } from 'vue-router'` → `import { useRoute, useRouter } from 'vue-router'`。
在 `const router = useRouter()`(line 27)后加:
```typescript
const route = useRoute()
```
- [ ] **Step 2: 加日期窗 computed(放在 `queryFor` 之前)**
```typescript
/** 从路由 query 读日期窗(从 StaffList stat modal 带窗跳来时有值);缺失=全量(向后兼容)。 */
const windowStart = computed(() => {
const s = Array.isArray(route.query.start) ? route.query.start[0] : route.query.start
return s != null && /^\d+$/.test(String(s)) ? Number(s) : undefined
})
const windowEnd = computed(() => {
const e = Array.isArray(route.query.end) ? route.query.end[0] : route.query.end
return e != null && /^\d+$/.test(String(e)) ? Number(e) : undefined
})
```
- [ ] **Step 3: `queryFor` 透传 start/end**
```typescript
function queryFor(pageNum: number) {
return {
channel: channelTyped.value,
owner_account_id: props.ownerId,
talker_id: isGroup.value ? undefined : props.talkerId,
room_id: isGroup.value ? props.roomId : undefined,
start: windowStart.value,
end: windowEnd.value,
page: pageNum,
page_size: PAGE_SIZE,
}
}
```
> `getMessages`→`clean()` 会 drop undefined,故无 query 时不过滤(现行行为不变)。
- [ ] **Step 4: `subtitle` 有窗时显时段**
把 `subtitle` computed 改为:
```typescript
const subtitle = computed(() => {
let s =
`渠道:${channelLabel(channelTyped.value)} · 归属账号:${props.ownerId}` +
(total.value > 0 ? ` · 共 ${total.value} 条` : '')
if (windowStart.value && windowEnd.value) {
s += ` · 时段:${formatTime(windowStart.value)} ~ ${formatTime(windowEnd.value)}`
}
return s
})
```
- [ ] **Step 5: `watch` 加 query 依赖(支持同路径改窗重载)**
```typescript
watch(
() =>
[props.channel, props.ownerId, props.talkerId, props.roomId, windowStart.value, windowEnd.value] as const,
() => {
loadInitial()
},
)
```
- [ ] **Step 6: 类型门禁**
```bash
cd yunkes && npx vue-tsc -b
```
Expected: exit 0。
- [ ] **Step 7: Commit**
```bash
git add yunkes/src/views/ChatTimeline.vue
git commit -m "feat(chat): ChatTimeline 支持 start/end query 日期窗过滤(从 stat modal 带窗跳入)"
```
End with blank line + `Co-Authored-By: Claude <noreply@anthropic.com>`。
---
## 验收(spec §10)
- [ ] `cd yunkes && npx vue-tsc -b` exit 0,无 console 报错。
- [ ] **浏览器实测**(本地后端 smoke.db + 前端,绝不碰云客):
- StaffList 微信员工行点 5 数字 → modal 列对齐云客:**序号 / 客户微信 / [有效沟通多发送·接收] / 员工信息 / 员工微信**;无操作列;序号跨页连续。
- 点 modal 客户微信 → 跳 `/chat/wechat/{owner}/{talker}?start=&end=`,URL 带窗;ChatTimeline **仅显示窗内消息**,副标题显时段;改 StaffList 日期窗 → modal 数字与跳转窗同步。
- 从其它入口进 ChatTimeline(CustomerList「查看聊天记录」、StaffList badge `viewChat`)无 query → 全量(向后兼容)。
- 员工姓名→drawer、badge→最近聊天 不变;WA tab 不变。
- network 仅 localhost(无云客)。
## 风险
- **ChatTimeline 过滤+分页**:`loadInitial` 先取 page1 算 total 再跳末页——带窗时 total 是窗内总数,末页为窗内最新,逻辑不变;`loadOlder` 往前翻亦在窗内。浏览器验。
- **字段缺失**:员工信息/员工微信 缺字段→`joinFields` 过滤掉,全缺显 `--`(不阻塞)。
- **同路径改窗**:`watch` 已加 query 依赖(Step 5)→ URL 改 start/end 会重载;主流程(modal 重新点击)走 `onMounted`。