docs(plan): ChatTimeline 会话列表 step5 实现计划(后端 /api/sessions + 双栏 + 选择器)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-08 14:47:49 +08:00
parent 73fd9d0975
commit c6dbda3bec
@@ -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 <noreply@anthropic.com>`。
---
## 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<SessionList> {
return getJson<SessionList>(
'/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 风格,`<script setup lang="ts">` + element plus):
```vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getSessions } from '@/api'
import type { ChatSession, Channel } from '@/api/types'
import { formatTime } from '@/utils/format'
const props = defineProps<{
channel: Channel
ownerId: string
activeType: 'friend' | 'group' | null
activeId: string | null
}>()
const emit = defineEmits<{ select: [ChatSession] }>()
const sessions = ref<ChatSession[]>([])
const loading = ref(false)
const errorMsg = ref<string | null>(null)
const filter = ref<'all' | 'friend' | 'group'>('all')
const keyword = ref('')
async function load() {
loading.value = true
errorMsg.value = null
try {
const res = await getSessions({ owner_account_id: props.ownerId, channel: props.channel })
sessions.value = res.sessions
} catch (e) {
errorMsg.value = (e as Error).message
sessions.value = []
} finally {
loading.value = false
}
}
const filtered = computed(() =>
sessions.value
.filter((s) => (filter.value === 'all' ? true : s.type === filter.value))
.filter((s) => {
const k = keyword.value.trim()
if (!k) return true
return (s.name ?? '').includes(k) || (s.talker_id ?? '').includes(k) || (s.room_id ?? '').includes(k)
}),
)
function isActive(s: ChatSession): boolean {
return props.activeType === s.type && props.activeId === (s.talker_id ?? s.room_id)
}
watch(() => [props.ownerId, props.channel] as const, load, { immediate: true })
</script>
<template>
<div class="yk-chat-side">
<div class="yk-chat-side__top">
<el-select v-model="filter" size="small" style="width: 96px">
<el-option label="全部" value="all" />
<el-option label="好友" value="friend" />
<el-option label="群聊" value="group" />
</el-select>
<el-input v-model="keyword" size="small" placeholder="搜索昵称/号" clearable />
</div>
<el-alert v-if="errorMsg" :title="errorMsg" type="error" :closable="false" />
<div v-loading="loading" class="yk-chat-side__list">
<div v-if="!loading && filtered.length === 0" class="yk-empty">暂无会话</div>
<div
v-for="s in filtered"
:key="(s.talker_id ?? s.room_id) + s.type"
class="yk-chat-side__item"
:class="{ 'is-active': isActive(s) }"
@click="emit('select', s)"
>
<el-avatar :size="36" :src="s.avatar ?? undefined">{{ (s.name ?? '?').slice(0, 1) }}</el-avatar>
<div class="yk-chat-side__meta">
<div class="yk-chat-side__name">
<span>{{ s.name ?? '--' }}</span>
<span class="yk-chat-side__today">今日{{ s.today_count }}</span>
</div>
<div class="yk-chat-side__time">{{ s.last_chat_time ? formatTime(s.last_chat_time) : '--' }}</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.yk-chat-side { display: flex; flex-direction: column; height: 100%; width: 260px; border-right: 1px solid var(--el-border-color-light); background: #fff; }
.yk-chat-side__top { display: flex; gap: 6px; padding: 8px; border-bottom: 1px solid var(--el-border-color-lighter); }
.yk-chat-side__list { flex: 1; overflow-y: auto; }
.yk-chat-side__item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; cursor: pointer; }
.yk-chat-side__item:hover { background: #f5f7fa; }
.yk-chat-side__item.is-active { background: #ecf5ff; }
.yk-chat-side__meta { min-width: 0; flex: 1; }
.yk-chat-side__name { display: flex; justify-content: space-between; gap: 6px; font-size: 13px; color: #303133; }
.yk-chat-side__today { font-size: 11px; color: #909399; flex-shrink: 0; }
.yk-chat-side__time { font-size: 11px; color: #c0c4cc; margin-top: 2px; }
</style>
```
- [ ] **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<string | null>(() => (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」包进右侧主区,左侧加 `<ChatSidebar>`;无 active 时显示空态:
```vue
<template>
<div class="yk-chat">
<div class="yk-chat__body">
<ChatSidebar
:channel="channelTyped"
:owner-id="props.ownerId"
:active-type="activeType"
:active-id="activeId"
@select="onSelectSession"
/>
<div class="yk-chat__main">
<div class="yk-chat__header">
<div>
<div style="display: flex; align-items: center; gap: 8px">
<el-button text size="small" @click="goBack"><el-icon><ArrowLeft /></el-icon> 返回</el-button>
<h2 class="yk-chat__title">{{ conversationTitle }}</h2>
</div>
<div class="yk-chat__sub">{{ subtitle }}</div>
</div>
<!-- Task 4 在此插日期选择器 + 关键词搜索 -->
</div>
<el-alert v-if="errorMsg" :title="`聊天记录加载失败:${errorMsg}`" type="error" show-icon :closable="false" />
<div v-if="!hasActive" class="yk-empty yk-chat__nopick">选择左侧会话查看消息</div>
<div v-else v-loading="loading" class="yk-chat__stream" ref="streamRef">
<!-- 现有 load-older + messages 不变 -->
</div>
</div>
</div>
<el-drawer ...><!-- 不变 --></el-drawer>
</div>
</template>
```
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
<div class="yk-chat__filters">
<el-date-picker v-model="dateRange" type="daterange" size="small" :clearable="false" @change="onDateChange" />
<el-input v-model="keyword" size="small" placeholder="搜索当前已加载消息" clearable style="width: 200px" />
</div>
```
- [ ] **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}。