Files
yunke/docs/superpowers/plans/2026-08-08-yunke-chat-sessions.md
T

28 KiB
Raw Blame History

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):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):GROUP BY + sa.case 计数 + _customer_names Python 侧 IN 查询合并(非 SQL join)。本 plan /api/sessions 照此。注:today_count 用数值 timestamp >= today_start 比较,无需 staff_chat_stats 的跨库日期方言分支(更简单)。
  • Python 3.12 → zoneinfo.ZoneInfo("Asia/Shanghai") 可用。
  • ChatTimeline 现状(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:/chat/:channel/:ownerId/:talkerId + /group/:channel/:ownerId/:roomId,name:'chat'/'group-chat',props:true。
  • StaffList viewChat(StaffList.vue:252):预取最近一条消息拿 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 末尾)
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
"""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)
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_api_sessions.py -v
  • Step 4: 写 router app/api/routers/sessions.py
"""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: 跑新测试 + 全量回归

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
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 追加类型
/** 账号会话列表一行(好友或群),镜像 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 块,字母序)
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):

<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
cd yunkes && npx vue-tsc -b
  • Step 5: Commit
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)
  {
    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: '群聊记录' },
  },

把现有 viewChat(预取最近消息拿 talkerId → push)改为直接账号级(删预取逻辑):

async function viewChat(row: Account) {
  // 账号级聊天窗口(左侧会话列表 + 右消息流);对齐云客 badge→聊天窗口。
  await router.push({ name: 'chat', params: { channel: row.channel, ownerId: row.account_id } })
}

删 import:getMessages 现仅 viewChat 用(StaffList.vue:254);改账号级后无消费者——必须从 line 5 import 移除(getAccounts, getCustomers, getStaffChatStats),否则 noUnusedLocals 报 vue-tsc 错。getCustomers(WA openAdded/openTalked 用)保留。onMounted/其它不动;删 viewChat 内的 ElMessage 提示(账号级总有效)。

  • Step 3: ChatTimeline 双栏化(模板 + script)

script 加:

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 时显示空态:

<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

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

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() }  // 改窗 → 重查当前会话

重要替换:删除 Task2 的 windowStart/windowEnd(route.query computed)与 hasWindow 逻辑——本 step 的 winStart/winEnd(上面 computed)取代之;subtitle(Task2 用 windowStart/windowEnd)改用 winStart/winEnd;queryFor/watch 见 Step2/Step5。

  • Step 2: queryFor 用 winStart/winEnd(替换 Task2 的 hasWindow/windowStart/windowEnd)
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: 关键词前端过滤(已加载页)
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 留位处)
<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 触发)
watch(
  () => [props.channel, props.ownerId, props.talkerId, props.roomId] as const,
  () => { loadInitial() },
)

subtitle 显窗用 winStart/winEnd(已有逻辑,变量名换)。

  • Step 6: vue-tsc + 浏览器实测
cd yunkes && npx vue-tsc -b
  • Step 7: Commit
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}。