From c6dbda3bec38307c9070861d60fb0d8b036b82a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sat, 8 Aug 2026 14:47:49 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20ChatTimeline=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=88=97=E8=A1=A8=20step5=20=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92(=E5=90=8E=E7=AB=AF=20/api/sessions=20+=20=E5=8F=8C?= =?UTF-8?q?=E6=A0=8F=20+=20=E9=80=89=E6=8B=A9=E5=99=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .../plans/2026-08-08-yunke-chat-sessions.md | 660 ++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-yunke-chat-sessions.md diff --git a/docs/superpowers/plans/2026-08-08-yunke-chat-sessions.md b/docs/superpowers/plans/2026-08-08-yunke-chat-sessions.md new file mode 100644 index 0000000..618f611 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-yunke-chat-sessions.md @@ -0,0 +1,660 @@ +# ChatTimeline 会话列表对齐云客(step 5)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`). + +**Goal:** ChatTimeline 升级为云客式双栏聊天窗口——左侧会话列表(账号的好友+群,带今日消息数+最近联系时间)+ 右侧消息流 + 页内日期选择器/关键词搜索;badge/昵称 → 账号级入口。 + +**Architecture:** 后端新 `GET /api/sessions`(message 表聚合好友/群 → today_count + last_chat_time,镜像 staff-chat_stats,无新表)。前端新 `ChatSidebar.vue`(读 /api/sessions),`ChatTimeline.vue` 改双栏(左 ChatSidebar + 右消息流),路由 talkerId/roomId 改可选,badge/昵称改账号级入口,页内日期选择器(本地 ref 跨会话持久)+ 关键词搜索(前端过滤已加载页)。 + +**Tech Stack:** FastAPI + SQLAlchemy 2.0 async(`yunke-server/`,pytest async);Vue3+TS+Element Plus(`yunkes/`,vue-tsc 门禁)。无新表/无 Alembic。 + +**Spec:** `docs/superpowers/specs/2026-08-08-yunke-chat-sessions-design.md`(已过审 + 已修时区漏洞)。 + +--- + +## 关键事实(plan 核实,codebase) + +- `ChatGroup` 模型([models.py:259](../../../yunke-server/app/db/models.py)):`room_id`(PK)/`channel`/`room_name`/`owner_account_id`/`head_image_url`/`last_chat_time`;索引 `ix_chat_group_owner(channel, owner_account_id, room_id)`。 +- `Customer`:`friend_id`(PK)/`channel`/`owner_account_id`/`friend_remark`/`friend_nickname`/`real_name`/`head_image_url`。 +- `Message`:`channel`/`owner_account_id`/`talker_id`/`room_id`(NULL/空=私聊)/`timestamp`(ms-epoch);索引 `ix_message_channel_owner_talker_time`。 +- staff_chat_stats 先例([staff_chat_stats.py](../../../yunke-server/app/api/routers/staff_chat_stats.py)):`session.bind.name` 方言检测、`GROUP BY`、`sa.case` 计数、`_customer_names` **Python 侧 IN 查询合并**(非 SQL join)。本 plan /api/sessions 照此。 +- Python 3.12 → `zoneinfo.ZoneInfo("Asia/Shanghai")` 可用。 +- ChatTimeline 现状([ChatTimeline.vue](../../../yunkes/src/views/ChatTimeline.vue)):`defineProps<{channel,ownerId,talkerId?,roomId?}>`;Task2 加的 `windowStart/windowEnd`(computed from `route.query`)、`queryFor`(带 `hasWindow` 成对 start/end)、`watch([props..., windowStart, windowEnd])`、`onMounted(loadInitial)`;模板 header(返回+title+subtitle)+ stream + drawer。 +- 路由 [router/index.ts:61-75](../../../yunkes/src/router/index.ts):`/chat/:channel/:ownerId/:talkerId` + `/group/:channel/:ownerId/:roomId`,`name:'chat'`/`'group-chat'`,`props:true`。 +- StaffList `viewChat`([StaffList.vue:252](../../../yunkes/src/views/StaffList.vue)):预取最近一条消息拿 talkerId → push `/chat/:channel/:ownerId/:talkerId`。本 plan 改账号级。 +- `/api/messages` **无 `q` 关键词参数**(spec §8)→ 关键词搜索前端过滤已加载页。 + +## 文件清单 + +**后端**:新 `app/api/routers/sessions.py` + `tests/test_api_sessions.py`;改 `app/api/schemas.py`(`SessionOut`/`SessionListOut`)、`app/api/main.py`(注册)。 +**前端**:新 `src/components/ChatSidebar.vue`;改 `src/views/ChatTimeline.vue`(双栏+选择器+搜索)、`src/router/index.ts`(可选参)、`src/views/StaffList.vue`(viewChat 账号级)、`src/api/index.ts`+`types.ts`(`getSessions`)。 + +--- + +## Task 1: 后端 `GET /api/sessions`(TDD) + +**Files:** Create `yunke-server/app/api/routers/sessions.py`、`tests/test_api_sessions.py`;Modify `app/api/schemas.py`、`app/api/main.py`。镜像 `staff_chat_stats.py` + `tests/test_api_staff_chat_stats.py` + `conftest.py` `make_client`。 + +- [ ] **Step 1: schemas(追加 `app/api/schemas.py` 末尾)** + +```python +class SessionOut(BaseModel): + """账号会话列表的一行(好友或群)。""" + + type: Literal["friend", "group"] + talker_id: str | None = None # type=friend 时有值 + room_id: str | None = None # type=group 时有值 + name: str | None = None # 昵称/群名,缺失则回退 id + avatar: str | None = None + today_count: int # 今日(Asia/Shanghai)消息数 + last_chat_time: int | None = None # 最近联系时间 ms-epoch + + +class SessionListOut(BaseModel): + owner_account_id: str + channel: str + today_start: int # Asia/Shanghai 当日 0 点 ms-epoch(供前端参考) + sessions: list[SessionOut] +``` + +> `Literal` 从 `typing` 导入(schemas.py 顶部若未有则加)。 + +- [ ] **Step 2: 写失败测试 `tests/test_api_sessions.py`** + +```python +"""Tests for GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间)。""" +from __future__ import annotations + +import time + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ChatGroup, Customer, Message + +OWNER = "wx_owner_s" +CH = "wechat" +_NOW = int(time.time() * 1000) +_MS_DAY = 86_400_000 + + +async def _seed(session: AsyncSession) -> None: + session.add_all([ + # t1: 今日有消息 + 2 天前 → today_count=1, last=NOW + Message(msg_id="t1a", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="x"), + Message(msg_id="t1b", channel=CH, owner_account_id=OWNER, talker_id="t1", room_id=None, is_send=False, timestamp=_NOW - 2 * _MS_DAY, type="text", type_raw=1, text="y"), + # t2: 仅 5 天前 → today_count=0 + Message(msg_id="t2a", channel=CH, owner_account_id=OWNER, talker_id="t2", room_id=None, is_send=True, timestamp=_NOW - 5 * _MS_DAY, type="text", type_raw=1, text="old"), + # 群 room1: 今日有消息 + Message(msg_id="g1a", channel=CH, owner_account_id=OWNER, talker_id="member_x", room_id="room1@chatroom", is_send=False, timestamp=_NOW, type="text", type_raw=1, text="g"), + # 他 owner 的消息 — 必须排除 + Message(msg_id="x1", channel=CH, owner_account_id="wx_other", talker_id="t1", room_id=None, is_send=True, timestamp=_NOW, type="text", type_raw=1, text="other"), + Customer(friend_id="t1", channel=CH, owner_account_id=OWNER, friend_nickname="Alice", friend_remark=None), + # t2 无 customer 行 → name 回退 talker_id "t2" + ChatGroup(room_id="room1@chatroom", channel=CH, owner_account_id=OWNER, room_name="群一号"), + ]) + await session.commit() + + +async def test_sessions_friend_and_group(make_client): + client = await make_client(_seed) + r = await client.get("/api/sessions", params={"owner_account_id": OWNER, "channel": CH}) + assert r.status_code == 200 + body = r.json() + assert body["owner_account_id"] == OWNER + by = {(s["type"], s.get("talker_id") or s.get("room_id")): s for s in body["sessions"]} + assert ("friend", "t1") in by and ("friend", "t2") in by and ("group", "room1@chatroom") in by + assert len(body["sessions"]) == 3 # 他 owner 排除 + # t1: 今日 1 条, last=NOW, name=Alice + t1 = by[("friend", "t1")] + assert t1["today_count"] == 1 + assert t1["last_chat_time"] == _NOW + assert t1["name"] == "Alice" + # t2: 今日 0 条, name 回退 "t2" + assert by[("friend", "t2")]["today_count"] == 0 + assert by[("friend", "t2")]["name"] == "t2" + # 群: name=群一号, today=1 + g = by[("group", "room1@chatroom")] + assert g["name"] == "群一号" and g["today_count"] == 1 + + +async def test_sessions_sorted_by_last_chat_time_desc(make_client): + client = await make_client(_seed) + body = (await client.get("/api/sessions", params={"owner_account_id": OWNER})).json() + last_times = [s["last_chat_time"] for s in body["sessions"]] + assert last_times == sorted(last_times, reverse=True) # 降序 + + +async def test_sessions_empty(make_client): + async def _empty(s: AsyncSession): + await s.commit() + client = await make_client(_empty) + r = await client.get("/api/sessions", params={"owner_account_id": OWNER}) + assert r.status_code == 200 + assert r.json()["sessions"] == [] +``` + +- [ ] **Step 3: 跑测试确认失败(404)** + +```bash +cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_sessions.py -v +``` + +- [ ] **Step 4: 写 router `app/api/routers/sessions.py`** + +```python +"""GET /api/sessions — 账号会话列表(好友+群,今日消息数+最近联系时间),全本地 message 聚合。 + +镜像 staff_chat_stats 聚合模式(GROUP BY + case 计数 + Python 侧合并昵称/头像)。 +today_start 钉死 Asia/Shanghai(UTC+8)当日 0 点,不依赖服务器时区。 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Annotated +from zoneinfo import ZoneInfo + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_session +from app.api.schemas import SessionListOut, SessionOut +from app.db.models import ChatGroup, Customer, Message + +router = APIRouter(prefix="/api", tags=["sessions"]) + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +_TZ = ZoneInfo("Asia/Shanghai") + + +def _today_start_ms() -> int: + """Asia/Shanghai 当日 0 点 ms-epoch(部署无关、测试确定性)。""" + midnight = datetime.now(_TZ).replace(hour=0, minute=0, second=0, microsecond=0) + return int(midnight.timestamp() * 1000) + + +def _base(msg, owner: str, channel: str): + return [ + msg.channel == channel, + msg.owner_account_id == owner, + msg.timestamp.is_not(None), + ] + + +async def _friend_rows(session: AsyncSession, owner: str, channel: str, today_start: int): + stmt = ( + sa.select( + Message.talker_id, + sa.func.max(Message.timestamp).label("last_chat_time"), + sa.func.sum(sa.case((Message.timestamp >= today_start, 1), else_=0)).label("today_count"), + ) + .where(*_base(Message, owner, channel), sa.or_(Message.room_id.is_(None), Message.room_id == ""), Message.talker_id.is_not(None)) + .group_by(Message.talker_id) + ) + return (await session.execute(stmt)).all() + + +async def _group_rows(session: AsyncSession, owner: str, channel: str, today_start: int): + stmt = ( + sa.select( + Message.room_id, + sa.func.max(Message.timestamp).label("last_chat_time"), + sa.func.sum(sa.case((Message.timestamp >= today_start, 1), else_=0)).label("today_count"), + ) + .where(*_base(Message, owner, channel), Message.room_id.is_not(None), Message.room_id != "") + .group_by(Message.room_id) + ) + return (await session.execute(stmt)).all() + + +async def _meta(session: AsyncSession, owner: str, channel: str, friend_ids: list[str], room_ids: list[str]): + friends: dict[str, tuple[str, str | None]] = {} + if friend_ids: + for fid, remark, nick, real, avatar in (await session.execute( + sa.select(Customer.friend_id, Customer.friend_remark, Customer.friend_nickname, Customer.real_name, Customer.head_image_url) + .where(Customer.channel == channel, Customer.owner_account_id == owner, Customer.friend_id.in_(friend_ids)) + )).all(): + friends[fid] = (remark or nick or real or fid, avatar) + groups: dict[str, tuple[str, str | None]] = {} + if room_ids: + for rid, name, avatar in (await session.execute( + sa.select(ChatGroup.room_id, ChatGroup.room_name, ChatGroup.head_image_url) + .where(ChatGroup.channel == channel, ChatGroup.owner_account_id == owner, ChatGroup.room_id.in_(room_ids)) + )).all(): + groups[rid] = (name or rid, avatar) + return friends, groups + + +@router.get("/sessions") +async def list_sessions( + session: SessionDep, + owner_account_id: Annotated[str, Query(min_length=1)], + channel: Annotated[str, Query()] = "wechat", +) -> SessionListOut: + """账号会话列表:好友(talker_id,room_id 空)+ 群(room_id),按 last_chat_time desc。""" + today_start = _today_start_ms() + f_rows = await _friend_rows(session, owner_account_id, channel, today_start) + g_rows = await _group_rows(session, owner_account_id, channel, today_start) + fnames, gnames = await _meta( + session, owner_account_id, channel, [r.talker_id for r in f_rows], [r.room_id for r in g_rows] + ) + sessions: list[SessionOut] = [] + for r in f_rows: + name, avatar = fnames.get(r.talker_id, (r.talker_id, None)) + sessions.append(SessionOut(type="friend", talker_id=r.talker_id, name=name, avatar=avatar, + today_count=int(r.today_count or 0), last_chat_time=r.last_chat_time)) + for r in g_rows: + name, avatar = gnames.get(r.room_id, (r.room_id, None)) + sessions.append(SessionOut(type="group", room_id=r.room_id, name=name, avatar=avatar, + today_count=int(r.today_count or 0), last_chat_time=r.last_chat_time)) + sessions.sort(key=lambda s: (s.last_chat_time is None, -(s.last_chat_time or 0))) + return SessionListOut(owner_account_id=owner_account_id, channel=channel, today_start=today_start, sessions=sessions) +``` + +- [ ] **Step 5: 注册 router(`app/api/main.py`)** — import 列表加 `sessions`(字母序在 `search,` 与 `sms,` 之间),`app.include_router(sessions.router)`。 + +- [ ] **Step 6: 跑新测试 + 全量回归** + +```bash +cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_sessions.py -v +cd yunke-server && .venv/Scripts/python.exe -m pytest -q +``` +Expected: 新 3 用例 + 全量(352 + 3 = 355)绿。 + +- [ ] **Step 7: Commit** + +```bash +git add yunke-server/app/api/routers/sessions.py yunke-server/app/api/schemas.py yunke-server/app/api/main.py yunke-server/tests/test_api_sessions.py +git commit -m "feat(api): /api/sessions 账号会话列表(好友+群,今日消息数+最近联系时间,全本地聚合)" +``` +End with blank line + `Co-Authored-By: Claude `。 + +--- + +## Task 2: 前端 `getSessions` + `ChatSidebar.vue` + +**Files:** Modify `yunkes/src/api/index.ts` + `types.ts`;Create `yunkes/src/components/ChatSidebar.vue`。 + +- [ ] **Step 1: `types.ts` 追加类型** + +```typescript +/** 账号会话列表一行(好友或群),镜像 backend SessionOut。 */ +export interface ChatSession { + type: 'friend' | 'group' + talker_id?: string | null + room_id?: string | null + name?: string | null + avatar?: string | null + today_count: number + last_chat_time?: number | null +} + +export interface SessionList { + owner_account_id: string + channel: string + today_start: number + sessions: ChatSession[] +} +``` + +- [ ] **Step 2: `index.ts` 加 client + re-export**(`SessionList` 加入 import 与 re-export 块,字母序) + +```typescript +export interface SessionQuery { + owner_account_id: string + channel?: Channel +} + +export function getSessions(query: SessionQuery): Promise { + return getJson( + '/sessions', + clean({ owner_account_id: query.owner_account_id, channel: query.channel }), + ) +} +``` + +- [ ] **Step 3: 写 `ChatSidebar.vue`** + +props:`channel`、`ownerId`、`activeType`(`'friend'|'group'|null`)、`activeId`(`string|null`)。emits:`select`(payload: `ChatSession`)。逻辑: +- `getSessions({owner_account_id: ownerId, channel})` → `sessions` ref;`loading/errorMsg`。 +- 本地 `filter`(`'all'|'friend'|'group'`,默认 `all`)+ `keyword`(按 name/talker_id/room_id 前端过滤)。 +- 渲染:顶部筛选下拉 + 搜索框;会话列表每条 = 头像(el-avatar fallback 首字) + 昵称 + `今日消息{today_count}` + `formatTime(last_chat_time)`;当前 `activeType+activeId` 高亮;`@click` → `emit('select', s)`。 +- 空态「暂无会话」。 + +完整组件(参考 StaffList/GroupList 风格,` + + + + +``` + +- [ ] **Step 4: vue-tsc** + +```bash +cd yunkes && npx vue-tsc -b +``` + +- [ ] **Step 5: Commit** + +```bash +git add yunkes/src/api/index.ts yunkes/src/api/types.ts yunkes/src/components/ChatSidebar.vue +git commit -m "feat(chat): getSessions + ChatSidebar 会话列表组件" +``` + +--- + +## Task 3: ChatTimeline 双栏 + 路由可选参 + badge 账号级入口 + +**Files:** Modify `yunkes/src/router/index.ts`、`yunkes/src/views/StaffList.vue`、`yunkes/src/views/ChatTimeline.vue`(双栏骨架;日期选择器放 Task 4,本 task 先保留 Task2 的 route-query 窗)。 + +- [ ] **Step 1: 路由 talkerId/roomId 改可选(`router/index.ts`)** + +```typescript + { + path: '/chat/:channel/:ownerId/:talkerId?', + name: 'chat', + component: () => import('@/views/ChatTimeline.vue'), + props: true, + meta: { title: '聊天记录' }, + }, + { + path: '/group/:channel/:ownerId/:roomId?', + name: 'group-chat', + component: () => import('@/views/ChatTimeline.vue'), + props: true, + meta: { title: '群聊记录' }, + }, +``` + +- [ ] **Step 2: StaffList `viewChat` 改账号级**([StaffList.vue:252](../../../yunkes/src/views/StaffList.vue)) + +把现有 `viewChat`(预取最近消息拿 talkerId → push)改为直接账号级(删预取逻辑): + +```typescript +async function viewChat(row: Account) { + // 账号级聊天窗口(左侧会话列表 + 右消息流);对齐云客 badge→聊天窗口。 + await router.push({ name: 'chat', params: { channel: row.channel, ownerId: row.account_id } }) +} +``` + +> `getMessages` import 若仅 viewChat 用则保留(其它地方可能用);onMounted/其它不动。删除 viewChat 内的 ElMessage 提示(账号级总有效)。 + +- [ ] **Step 3: ChatTimeline 双栏化(模板 + script)** + +script 加: +```typescript +import ChatSidebar from '@/components/ChatSidebar.vue' +// 当前会话类型/id(高亮 sidebar) +const activeType = computed<'friend' | 'group' | null>(() => (isGroup.value ? 'group' : (props.talkerId ? 'friend' : null))) +const activeId = computed(() => (props.talkerId ?? props.roomId ?? null)) +const hasActive = computed(() => !!activeId.value) + +function onSelectSession(s: { type: 'friend' | 'group'; talker_id?: string | null; room_id?: string | null }) { + if (s.type === 'group') { + router.push({ name: 'group-chat', params: { channel: props.channel, ownerId: props.ownerId, roomId: s.room_id! } }) + } else { + router.push({ name: 'chat', params: { channel: props.channel, ownerId: props.ownerId, talkerId: s.talker_id! } }) + } +} +``` + +模板把现有「header + alert + stream」包进右侧主区,左侧加 ``;无 active 时显示空态: + +```vue + +``` + +style 加 `.yk-chat__body{display:flex;height:...}` / `.yk-chat__main{flex:1;display:flex;flex-direction:column;min-width:0}`。保留现有 stream 样式。 + +> 注意:`loadInitial` 在 `hasActive=false` 时不应请求消息——加守卫 `if (!hasActive.value) { messages.value=[]; return }` 于 loadInitial 顶部;watch 同理。 + +- [ ] **Step 4: vue-tsc + 浏览器速查**(账号级入口能开,侧栏渲染,空态显示) + +- [ ] **Step 5: Commit** + +```bash +git add yunkes/src/router/index.ts yunkes/src/views/StaffList.vue yunkes/src/views/ChatTimeline.vue +git commit -m "feat(chat): ChatTimeline 双栏化(左会话列表)+ 路由可选参 + badge 账号级入口" +``` + +--- + +## Task 4: 页内日期选择器 + 关键词搜索 + +**Files:** Modify `yunkes/src/views/ChatTimeline.vue`。 + +- [ ] **Step 1: 日期窗改本地 ref(跨会话持久)+ 关键词 ref** + +替换 Task2 的 `windowStart/windowEnd`(route.query computed)为本地 ref,`onMounted` 初值取 `route.query` 或默认近 2 周: + +```typescript +import { useRoute } from 'vue-router' // 已 import +const route = useRoute() +const TWO_WEEKS_MS = 14 * 86_400_000 + +function defaultRange(): [Date, Date] { + const end = new Date(); end.setHours(23, 59, 59, 999) + const start = new Date(end.getTime() - TWO_WEEKS_MS); start.setHours(0, 0, 0, 0) + return [start, end] +} +function rangeFromQuery(): [Date, Date] | null { + const s = route.query.start, e = route.query.end + const sv = Array.isArray(s) ? s[0] : s, ev = Array.isArray(e) ? e[0] : e + if (sv && ev && /^\d+$/.test(String(sv)) && /^\d+$/.test(String(ev))) { + return [new Date(Number(sv)), new Date(Number(ev))] + } + return null +} +const dateRange = ref<[Date, Date]>(rangeFromQuery() ?? defaultRange()) +const winStart = computed(() => dateRange.value[0].getTime()) +const winEnd = computed(() => dateRange.value[1].getTime()) +const keyword = ref('') + +function onDateChange() { loadInitial() } // 改窗 → 重查当前会话 +``` + +- [ ] **Step 2: `queryFor` 用 winStart/winEnd**(替换 Task2 的 hasWindow/windowStart/windowEnd) + +```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: winStart.value, + end: winEnd.value, + page: pageNum, + page_size: PAGE_SIZE, + } +} +``` + +- [ ] **Step 3: 关键词前端过滤(已加载页)** + +```typescript +const visibleMessages = computed(() => { + const k = keyword.value.trim() + if (!k) return messages.value + return messages.value.filter((m) => (m.text ?? '').includes(k)) +}) +``` +模板 `v-for="m in visibleMessages"`(替换 `messages`)。空态文案:有 keyword 但无匹配 → 「当前已加载消息中无匹配」。 + +- [ ] **Step 4: 模板头部插日期选择器 + 关键词搜索**(在 Task3 留位处) + +```vue +
+ + +
+``` + +- [ ] **Step 5: `watch` 改回 props-only(dateRange 本地 ref 不入 watch,改由 onDateChange 触发)** + +```typescript +watch( + () => [props.channel, props.ownerId, props.talkerId, props.roomId] as const, + () => { loadInitial() }, +) +``` +> subtitle 显窗用 winStart/winEnd(已有逻辑,变量名换)。 + +- [ ] **Step 6: vue-tsc + 浏览器实测** + +```bash +cd yunkes && npx vue-tsc -b +``` + +- [ ] **Step 7: Commit** + +```bash +git add yunkes/src/views/ChatTimeline.vue +git commit -m "feat(chat): 页内日期选择器(跨会话持久)+ 关键词搜索(已加载页过滤)" +``` + +--- + +## 验收(spec §7) + +- [ ] 后端 `pytest -q` 全绿(含 `test_api_sessions.py` 3 用例)。 +- [ ] `vue-tsc -b` exit 0。 +- [ ] **浏览器实测**(本地 smoke.db,无云客): + - StaffList 点 badge/昵称 → `/chat/:channel/:ownerId`(账号级),左侧会话列表(头像/昵称/今日消息/最近联系)+ 右空态。 + - 点会话 → 右消息流加载;侧栏常驻、当前会话高亮;切好友↔群(/chat↔/group)侧栏不消失。 + - 页内日期选择器改窗 → 消息流重查(会话列表不变);关键词搜索过滤已加载消息。 + - stat modal 客户微信 → `/chat/.../:talkerId?start=&end=`,侧栏 + 该会话 + 选择器初值取 query(近3天窗)。 + - CustomerList/GroupList「查看聊天记录」深链照旧(侧栏+该会话)。 + - network 仅 localhost。 + +## 风险 + +- **今日计数**:`today_start` 钉 Asia/Shanghai(UTC+8),测试用 `time.time()`(当下)+ `time.time()-N天` 对比,确定性。 +- **smoke.db 群数据稀疏**(Plan 3 gap):/api/sessions 群会话从 message(room_id)派生,即使 chat_group 表空也能出(名称回退 room_id);浏览器验以好友会话为主。 +- **ChatTimeline 重构面大**:分 Task3(双栏骨架,保留 Task2 窗)+ Task4(选择器本地 ref)两步,每步 vue-tsc + 浏览器验,保持 stat-modal/深链入口向后兼容。 +- **关键词仅过滤已加载页**(~50 条近期):UI placeholder 明示「搜索当前已加载消息」(非全历史)。 +- **WA 通道**:/api/sessions `channel` 参数化,WA 好友会话可出(WA 群视数据有无);badge 对 WA 行也走账号级 /chat/whatsapp/{uid}。