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:
86
development-doc/daily/2026-04-07.md
Normal file
86
development-doc/daily/2026-04-07.md
Normal 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”功能
|
||||
@@ -616,8 +616,8 @@
|
||||
|
||||
/* ── Conversation Sidebar ── */
|
||||
.conv-sidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid rgba(0, 245, 212, 0.15);
|
||||
display: flex;
|
||||
@@ -948,18 +948,28 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-model {
|
||||
.chat-model-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent-amber);
|
||||
padding: 3px 10px;
|
||||
border: 1px solid rgba(249, 168, 37, 0.2);
|
||||
border-radius: 20px;
|
||||
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 ── */
|
||||
@@ -1775,6 +1785,7 @@
|
||||
padding: 3px 0;
|
||||
border-radius: 2px;
|
||||
color: rgba(0, 243, 255, 0.4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-day.active {
|
||||
@@ -2279,6 +2290,7 @@
|
||||
border: 1px solid rgba(73, 208, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: rgba(208, 240, 252, 0.68);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.calendar-day.muted {
|
||||
@@ -2304,6 +2316,37 @@
|
||||
border-color: rgba(125, 211, 252, 0.78);
|
||||
font-weight: 700;
|
||||
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 {
|
||||
|
||||
@@ -729,6 +729,7 @@ export function useChatView() {
|
||||
sendMessage,
|
||||
selectConversation,
|
||||
newConversation,
|
||||
loadConversations,
|
||||
deleteConversation,
|
||||
formatTime,
|
||||
formatConvDate,
|
||||
|
||||
@@ -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 { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
|
||||
import type { Conversation } from '@/api/conversation'
|
||||
|
||||
export interface SidebarFocusItem {
|
||||
id: string; label: string; title: string; meta: string; tone: 'done' | 'doing' | 'pending'
|
||||
@@ -30,9 +31,20 @@ function formatMonthKey(date: Date) {
|
||||
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 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 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 daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
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) {
|
||||
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) {
|
||||
const monthDate = new Date(year, month, day)
|
||||
const dateKey = formatDateKey(monthDate)
|
||||
const summary = monthPlanSummaryMap.value.get(dateKey)
|
||||
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) {
|
||||
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
|
||||
})
|
||||
@@ -201,6 +214,10 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
|
||||
void loadSidebarPlanSnapshot(clientTimeRef.value)
|
||||
})
|
||||
|
||||
function selectCalendarDate(dateKey: string) {
|
||||
selectedDate.value = dateKey
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadDailyDigestFn()
|
||||
void loadSidebarPlanSnapshot(new Date())
|
||||
@@ -211,6 +228,7 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
|
||||
calendarCells, calendarYear, calendarMonth, todayPlanCounters, monthReviewStats,
|
||||
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
|
||||
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
|
||||
sidebarFeedItems, topbarFeedItems, loadSidebarPlanSnapshot, sidebarCollapsedModules
|
||||
sidebarFeedItems, topbarFeedItems, loadSidebarPlanSnapshot, sidebarCollapsedModules,
|
||||
selectedDate, selectCalendarDate, conversationDateMap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ const {
|
||||
systemTelemetry,
|
||||
sessionTelemetry,
|
||||
sendMessage,
|
||||
selectConversation,
|
||||
newConversation,
|
||||
loadConversations,
|
||||
formatTime,
|
||||
autoResize,
|
||||
handleFileSelect,
|
||||
@@ -74,8 +77,9 @@ const {
|
||||
calendarCells, calendarYear, calendarMonth, todayPlanCounters,
|
||||
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
|
||||
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
|
||||
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules
|
||||
} = useSidebarPlan(clientTime, loadDailyDigest)
|
||||
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules,
|
||||
selectedDate, selectCalendarDate
|
||||
} = useSidebarPlan(clientTime, loadDailyDigest, store.conversations)
|
||||
|
||||
// --- Local UI state ---
|
||||
const sidebarCollapsed = ref(false)
|
||||
@@ -111,6 +115,33 @@ function closeKnowledgeHud() { knowledgeHudOpen.value = false }
|
||||
function handleSelectFolder(folder: any) { selectedFolder.value = folder }
|
||||
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) ---
|
||||
function formatUptime(seconds: number) {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
@@ -269,9 +300,11 @@ function renderMarkdown(content: string) {
|
||||
v-for="cell in calendarCells"
|
||||
:key="cell.key"
|
||||
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 ?? '' }}
|
||||
<span v-if="cell.hasConversation" class="conv-indicator"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,24 +388,11 @@ function renderMarkdown(content: string) {
|
||||
<div class="chat-model-panel">
|
||||
<label class="chat-model-label" for="chat-model-select">
|
||||
<Sparkles :size="12" />
|
||||
<span>CHAT MODEL</span>
|
||||
<span>MODEL</span>
|
||||
</label>
|
||||
<select
|
||||
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">
|
||||
<div class="chat-model-display">
|
||||
<Sparkles :size="12" />
|
||||
<span>{{ selectedModel.model }}</span>
|
||||
<span>{{ selectedModel?.model || selectedModelName || 'Default' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,21 +598,6 @@ function renderMarkdown(content: string) {
|
||||
<div class="section-label">// RUNTIME STATUS</div>
|
||||
<div class="runtime-meta-panel runtime-meta-panel-merged">
|
||||
<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">
|
||||
<span class="runtime-meta-key">OS</span>
|
||||
<span class="runtime-meta-value">{{ systemMeta.systemName }}</span>
|
||||
|
||||
Reference in New Issue
Block a user