docs(plan): ChatTimeline 会话列表 step5 实现计划(后端 /api/sessions + 双栏 + 选择器)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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}。
|
||||
Reference in New Issue
Block a user