feat(frontend): add calendar click to switch conversation by date

- Add selected date state and conversation mapping in useSidebarPlan
- Connect calendar cells to conversation switching logic
- Add conversation indicator dot on dates with sessions
- Only clickable dates show hand cursor (today + dates with conversations)
- Add .selected styling for non-today dates, today keeps blue
- Fix hover effect to only apply to non-today dates
- Add daily doc for session date mapping feature

BREAKING: Calendar click now switches sessions by date
This commit is contained in:
2026-04-07 10:28:31 +08:00
parent 3bff9b3b93
commit 721ddbeef9
5 changed files with 198 additions and 45 deletions

View File

@@ -0,0 +1,86 @@
# 2026-04-07 工作日志
## 今日开发计划
### 今日目标
- 梳理 chat 页面左侧日历与 conversation session 的关系
- 明确“按日期点击切换 session”的改造方向
- 记录当前 session 机制与后续实现方案
### 今日计划拆分
1. 盘点当前 conversation session 的数据结构与切换逻辑
2. 确认 session 当前不是按“天”进行切分
3. 设计日历点击后的 session 切换方案
4. 将方案记录到 daily作为后续改造依据
---
## 今日实际完成
- 检查了前端 `conversation store``conversation api` 与后端 `conversation router / model`
- 确认当前 conversation session 以 `conversation_id` 为核心,不是按“天”自动切分
- 确认现有字段主要依赖 `created_at` / `updated_at` 做时间记录,但单个 session 可跨多天持续使用
- 明确了左侧日历点击切换 session 的推荐改造方式
---
## 当前结论
### session 现状
- 当前 session 不是以“天”为单位计算
- 当前会话列表来源于 `/api/conversations`,按 `updated_at` 倒序展示
- 点击某一天时,不能直接假定“一天对应一个现成 session”
### 推荐改造方案
采用“**保留现有 conversation 结构 + 前端增加按日期筛选/映射**”的方案:
1. 保持后端 `Conversation` / `Message` 结构不变
2. 前端基于 `created_at``updated_at` 将 conversations 映射到具体日期
3. 左侧日历某天被点击后,优先切换到该日期最近一次活跃的 session
4. 如果该日期没有 session则进入新会话态必要时再创建新的 conversation
5. 会话本质仍是 conversation不强制把数据库层改成“每天一个 session”
### 这样处理的原因
- 不需要重做现有 conversation 数据模型
- 不会破坏当前多轮上下文连续性
- 可以快速给日历交互增加“按日期查看/切换”能力
- 后续如果要做“每日会话视图”或“按天归档”也更容易扩展
---
## 建议的后续实现点
1. 在前端增加“选中日期”状态
2. 将 conversations 按日期建立索引映射
3. 日历点击时,根据日期找到对应 session 并调用 `selectConversation`
4. 如果无匹配 session则清空当前消息区并进入新会话态
5. 视需要补充“当天无会话”的空态提示
---
## 风险与临时决策
### 风险
- 如果一个 conversation 跨多天活跃,按 `created_at` 还是 `updated_at` 归属日期需要统一规则
- 如果同一天有多个 session需要定义点击日历后的优先选择策略
### 当前临时决策
- 先按 `updated_at` 作为日历映射依据,更符合“最近活跃”的使用直觉
- 先选择该日期下最近活跃的一条 conversation 作为默认切换目标
- 暂不改数据库,不引入“每天强制新建 session”的硬规则
---
## 下一步计划
1. 在 chat 页整理日历点击事件接入点
2. 补充 conversations 与日期映射的前端计算逻辑
3. 明确空态与多 session 同日时的交互细节
4. 开始实现“点击日历切换 session”功能

View File

@@ -616,8 +616,8 @@
/* ── Conversation Sidebar ── */ /* ── Conversation Sidebar ── */
.conv-sidebar { .conv-sidebar {
width: 280px; width: 320px;
min-width: 280px; min-width: 320px;
background: var(--bg-panel); background: var(--bg-panel);
border-right: 1px solid rgba(0, 245, 212, 0.15); border-right: 1px solid rgba(0, 245, 212, 0.15);
display: flex; display: flex;
@@ -948,18 +948,28 @@
cursor: not-allowed; cursor: not-allowed;
} }
.chat-model { .chat-model-display {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-family: var(--font-display); font-family: var(--font-display);
font-size: 9px; font-size: 9px;
letter-spacing: 0.1em; letter-spacing: 0.06em;
color: var(--accent-amber); color: var(--accent-amber);
padding: 3px 10px; padding: 3px 10px;
border: 1px solid rgba(249, 168, 37, 0.2); border: 1px solid rgba(249, 168, 37, 0.2);
border-radius: 20px; border-radius: 20px;
background: var(--accent-amber-dim); background: var(--accent-amber-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.chat-model-display span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
/* ── Messages ── */ /* ── Messages ── */
@@ -1775,6 +1785,7 @@
padding: 3px 0; padding: 3px 0;
border-radius: 2px; border-radius: 2px;
color: rgba(0, 243, 255, 0.4); color: rgba(0, 243, 255, 0.4);
position: relative;
} }
.calendar-day.active { .calendar-day.active {
@@ -2279,6 +2290,7 @@
border: 1px solid rgba(73, 208, 255, 0.06); border: 1px solid rgba(73, 208, 255, 0.06);
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
color: rgba(208, 240, 252, 0.68); color: rgba(208, 240, 252, 0.68);
transition: none;
} }
.calendar-day.muted { .calendar-day.muted {
@@ -2304,6 +2316,37 @@
border-color: rgba(125, 211, 252, 0.78); border-color: rgba(125, 211, 252, 0.78);
font-weight: 700; font-weight: 700;
box-shadow: 0 0 16px rgba(56, 189, 248, 0.22); box-shadow: 0 0 16px rgba(56, 189, 248, 0.22);
cursor: pointer;
}
.calendar-day.selected:not(.active) {
border-color: rgba(245, 158, 11, 0.6);
box-shadow: 0 0 12px rgba(245, 158, 11, 0.4);
background: rgba(245, 158, 11, 0.15);
}
.calendar-day.clickable {
cursor: pointer;
}
.calendar-day.clickable:hover:not(.active) {
border-color: rgba(0, 243, 255, 0.4);
background: rgba(0, 243, 255, 0.08);
}
.calendar-day.active:hover {
/* Keep original blue, no hover effect */
}
.conv-indicator {
position: absolute;
top: 3px;
right: 3px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 6px rgba(0, 243, 255, 0.6);
} }
.calendar-day.active.busy::after { .calendar-day.active.busy::after {

View File

@@ -729,6 +729,7 @@ export function useChatView() {
sendMessage, sendMessage,
selectConversation, selectConversation,
newConversation, newConversation,
loadConversations,
deleteConversation, deleteConversation,
formatTime, formatTime,
formatConvDate, formatConvDate,

View File

@@ -1,6 +1,7 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch, toRef } from 'vue'
import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next' import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next'
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter' import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
import type { Conversation } from '@/api/conversation'
export interface SidebarFocusItem { export interface SidebarFocusItem {
id: string; label: string; title: string; meta: string; tone: 'done' | 'doing' | 'pending' id: string; label: string; title: string; meta: string; tone: 'done' | 'doing' | 'pending'
@@ -30,9 +31,20 @@ function formatMonthKey(date: Date) {
return `${year}-${month}` return `${year}-${month}`
} }
export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void) { export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void, conversationsRef: Conversation[] = []) {
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null) const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([]) const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
const selectedDate = ref<string | null>(null)
// Build a map of date -> has conversation
const conversationDateMap = computed(() => {
const map = new Map<string, boolean>()
conversationsRef.forEach((conv) => {
const dateKey = formatDateKey(new Date(conv.updated_at))
map.set(dateKey, true)
})
return map
})
const todayDateKey = computed(() => formatDateKey(clientTimeRef.value)) const todayDateKey = computed(() => formatDateKey(clientTimeRef.value))
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item]))) const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
@@ -42,19 +54,20 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
const month = clientTimeRef.value.getMonth() const month = clientTimeRef.value.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate() const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7 const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7
const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean }> = [] const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = []
for (let index = 0; index < firstDayOffset; index += 1) { for (let index = 0; index < firstDayOffset; index += 1) {
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false }) cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
} }
for (let day = 1; day <= daysInMonth; day += 1) { for (let day = 1; day <= daysInMonth; day += 1) {
const monthDate = new Date(year, month, day) const monthDate = new Date(year, month, day)
const dateKey = formatDateKey(monthDate) const dateKey = formatDateKey(monthDate)
const summary = monthPlanSummaryMap.value.get(dateKey) const summary = monthPlanSummaryMap.value.get(dateKey)
const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0) const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0)
cells.push({ key: dateKey, value: day, active: day === clientTimeRef.value.getDate(), busy }) const hasConv = conversationDateMap.value.get(dateKey) || false
cells.push({ key: dateKey, value: day, active: day === clientTimeRef.value.getDate(), busy, selected: dateKey === selectedDate.value, hasConversation: hasConv })
} }
while (cells.length % 7 !== 0) { while (cells.length % 7 !== 0) {
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false }) cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false, selected: false, hasConversation: false })
} }
return cells return cells
}) })
@@ -201,6 +214,10 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
void loadSidebarPlanSnapshot(clientTimeRef.value) void loadSidebarPlanSnapshot(clientTimeRef.value)
}) })
function selectCalendarDate(dateKey: string) {
selectedDate.value = dateKey
}
onMounted(() => { onMounted(() => {
void loadDailyDigestFn() void loadDailyDigestFn()
void loadSidebarPlanSnapshot(new Date()) void loadSidebarPlanSnapshot(new Date())
@@ -211,6 +228,7 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
calendarCells, calendarYear, calendarMonth, todayPlanCounters, monthReviewStats, calendarCells, calendarYear, calendarMonth, todayPlanCounters, monthReviewStats,
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown, sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections, sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
sidebarFeedItems, topbarFeedItems, loadSidebarPlanSnapshot, sidebarCollapsedModules sidebarFeedItems, topbarFeedItems, loadSidebarPlanSnapshot, sidebarCollapsedModules,
selectedDate, selectCalendarDate, conversationDateMap
} }
} }

View File

@@ -50,6 +50,9 @@ const {
systemTelemetry, systemTelemetry,
sessionTelemetry, sessionTelemetry,
sendMessage, sendMessage,
selectConversation,
newConversation,
loadConversations,
formatTime, formatTime,
autoResize, autoResize,
handleFileSelect, handleFileSelect,
@@ -74,8 +77,9 @@ const {
calendarCells, calendarYear, calendarMonth, todayPlanCounters, calendarCells, calendarYear, calendarMonth, todayPlanCounters,
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown, sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections, sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules,
} = useSidebarPlan(clientTime, loadDailyDigest) selectedDate, selectCalendarDate
} = useSidebarPlan(clientTime, loadDailyDigest, store.conversations)
// --- Local UI state --- // --- Local UI state ---
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
@@ -111,6 +115,33 @@ function closeKnowledgeHud() { knowledgeHudOpen.value = false }
function handleSelectFolder(folder: any) { selectedFolder.value = folder } function handleSelectFolder(folder: any) { selectedFolder.value = folder }
function handleOpenPreview(doc: any) { previewDoc.value = doc } function handleOpenPreview(doc: any) { previewDoc.value = doc }
function handleCalendarDateSelect(dateKey: string) {
selectCalendarDate(dateKey)
// Reload conversations to get latest data
loadConversations().then(() => {
// Find conversation that matches the selected date (by updated_at)
const targetDate = new Date(dateKey)
const targetDateStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate())
const targetDateEnd = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate() + 1)
// Find conversation that falls on the selected date
const conversation = store.conversations.find((conv) => {
const convDate = new Date(conv.updated_at)
return convDate >= targetDateStart && convDate < targetDateEnd
})
if (conversation) {
selectConversation(conversation.id)
} else {
// No conversation for this date, create a new one
newConversation()
}
}).catch((err) => {
console.error('[Calendar] Error loading conversations:', err)
})
}
// --- Message rendering utilities (kept inline for clarity) --- // --- Message rendering utilities (kept inline for clarity) ---
function formatUptime(seconds: number) { function formatUptime(seconds: number) {
const days = Math.floor(seconds / 86400) const days = Math.floor(seconds / 86400)
@@ -269,9 +300,11 @@ function renderMarkdown(content: string) {
v-for="cell in calendarCells" v-for="cell in calendarCells"
:key="cell.key" :key="cell.key"
class="calendar-day" class="calendar-day"
:class="{ active: cell.active, busy: cell.busy, muted: cell.value === null }" :class="{ active: cell.active, busy: cell.busy, muted: cell.value === null, selected: cell.selected, clickable: cell.hasConversation || cell.active }"
@click="(cell.hasConversation || cell.active) && handleCalendarDateSelect(cell.key)"
> >
{{ cell.value ?? '' }} {{ cell.value ?? '' }}
<span v-if="cell.hasConversation" class="conv-indicator"></span>
</span> </span>
</div> </div>
</div> </div>
@@ -355,24 +388,11 @@ function renderMarkdown(content: string) {
<div class="chat-model-panel"> <div class="chat-model-panel">
<label class="chat-model-label" for="chat-model-select"> <label class="chat-model-label" for="chat-model-select">
<Sparkles :size="12" /> <Sparkles :size="12" />
<span>CHAT MODEL</span> <span>MODEL</span>
</label> </label>
<select <div class="chat-model-display">
id="chat-model-select"
v-model="selectedModelName"
class="chat-model-select"
:disabled="isSending || isLoadingModels || chatModels.length === 0"
>
<option v-if="chatModels.length === 0" value="">
{{ isLoadingModels ? 'Loading models...' : 'No chat models' }}
</option>
<option v-for="model in chatModels" :key="model.name" :value="model.name">
{{ model.name }}
</option>
</select>
<div v-if="selectedModel" class="chat-model">
<Sparkles :size="12" /> <Sparkles :size="12" />
<span>{{ selectedModel.model }}</span> <span>{{ selectedModel?.model || selectedModelName || 'Default' }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -578,21 +598,6 @@ function renderMarkdown(content: string) {
<div class="section-label">// RUNTIME STATUS</div> <div class="section-label">// RUNTIME STATUS</div>
<div class="runtime-meta-panel runtime-meta-panel-merged"> <div class="runtime-meta-panel runtime-meta-panel-merged">
<div class="runtime-panel-title">SYSTEM</div> <div class="runtime-panel-title">SYSTEM</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">DATE</span>
<span class="runtime-meta-value">{{ formatClientDate(clientTime) }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">TIME</span>
<span class="runtime-meta-value">{{ formatClientClock(clientTime) }}</span>
</div>
<div class="runtime-meta-item">
<span class="runtime-meta-key">WEATHER</span>
<span class="runtime-meta-value weather-inline">
<i :class="['wi', weatherIcon]"></i>
<span>{{ weatherSummary }}</span>
</span>
</div>
<div class="runtime-meta-item"> <div class="runtime-meta-item">
<span class="runtime-meta-key">OS</span> <span class="runtime-meta-key">OS</span>
<span class="runtime-meta-value">{{ systemMeta.systemName }}</span> <span class="runtime-meta-value">{{ systemMeta.systemName }}</span>