feat(frontend): add memory components, temple/war-room pages, and composables
- Add DailyDigestCard and ReminderToast memory components - Add temple and war-room page routes - Add memory API module with TypeScript definitions - Add chat composables: useClientTime, useDailyDigest, useSidebarPlan - Simplify chat/logs/settings pages (remove unused code) - Add settingsPage.css
This commit is contained in:
@@ -1366,6 +1366,67 @@
|
||||
100% { transform: translateY(12px); }
|
||||
}
|
||||
|
||||
/* ── Top Buttons Row ── */
|
||||
.top-buttons-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 14px 24px 10px;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
background: rgba(5, 8, 16, 0.6);
|
||||
}
|
||||
|
||||
.top-action-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, rgba(0, 245, 212, 0.08) 0%, rgba(123, 44, 191, 0.05) 100%);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-action-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 245, 212, 0.1), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.top-action-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.top-action-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background: linear-gradient(135deg, rgba(0, 245, 212, 0.15) 0%, rgba(123, 44, 191, 0.1) 100%);
|
||||
box-shadow: 0 0 20px rgba(0, 245, 212, 0.2), inset 0 0 20px rgba(0, 245, 212, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.top-action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.top-action-btn .btn-icon {
|
||||
font-size: 18px;
|
||||
filter: drop-shadow(0 0 4px rgba(0, 245, 212, 0.5));
|
||||
}
|
||||
|
||||
.top-action-btn .btn-text {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Input Area ── */
|
||||
.input-area {
|
||||
padding: 16px 24px 20px;
|
||||
|
||||
93
frontend/src/pages/chat/composables/useClientTime.ts
Normal file
93
frontend/src/pages/chat/composables/useClientTime.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, Sun } from 'lucide-vue-next'
|
||||
|
||||
export function formatNetworkRate(bytesPerSecond: number | null, online: boolean) {
|
||||
if (!online || bytesPerSecond === null) return 'OFFLINE'
|
||||
if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / (1024 * 1024)).toFixed(2)} MB/s`
|
||||
if (bytesPerSecond >= 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
||||
return `${bytesPerSecond.toFixed(0)} B/s`
|
||||
}
|
||||
|
||||
export function useClientTime() {
|
||||
const clientTime = ref(new Date())
|
||||
const weatherSummary = ref('Weather unavailable')
|
||||
const weatherCode = ref<number | null>(null)
|
||||
let clientTimeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function updateClientTime() {
|
||||
clientTime.value = new Date()
|
||||
}
|
||||
|
||||
function formatClientDate(date: Date) {
|
||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
function formatClientClock(date: Date) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function weatherCodeLabel(code: number | null | undefined) {
|
||||
if (code === 0) return 'Clear'
|
||||
if (code === 1 || code === 2) return 'Partly Cloudy'
|
||||
if (code === 3) return 'Overcast'
|
||||
if (code === 45 || code === 48) return 'Fog'
|
||||
if ([51, 53, 55, 56, 57].includes(code ?? -1)) return 'Drizzle'
|
||||
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return 'Rain'
|
||||
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow'
|
||||
if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm'
|
||||
return 'Weather'
|
||||
}
|
||||
|
||||
const weatherIcon = computed(() => {
|
||||
const code = weatherCode.value
|
||||
if (code === 0) return Sun
|
||||
if (code === 1 || code === 2 || code === 3) return Cloud
|
||||
if (code === 45 || code === 48) return CloudFog
|
||||
if ([51, 53, 55, 56, 57].includes(code ?? -1)) return CloudDrizzle
|
||||
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return CloudRain
|
||||
if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return CloudSnow
|
||||
if ([95, 96, 99].includes(code ?? -1)) return CloudLightning
|
||||
return Cloud
|
||||
})
|
||||
|
||||
async function loadWeather(latitude: number, longitude: number) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weather_code&timezone=auto`,
|
||||
)
|
||||
if (!response.ok) throw new Error('weather request failed')
|
||||
const data = await response.json()
|
||||
const current = data.current ?? {}
|
||||
weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null
|
||||
const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--'
|
||||
weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}`
|
||||
} catch {
|
||||
weatherCode.value = null
|
||||
weatherSummary.value = 'Weather unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateClientTime()
|
||||
clientTimeTimer = setInterval(updateClientTime, 1000)
|
||||
if (!navigator.geolocation) {
|
||||
weatherCode.value = null
|
||||
weatherSummary.value = 'Weather unavailable'
|
||||
return
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => { void loadWeather(position.coords.latitude, position.coords.longitude) },
|
||||
() => { weatherCode.value = null; weatherSummary.value = 'Weather unavailable' },
|
||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 },
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clientTimeTimer) clearInterval(clientTimeTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
clientTime, weatherSummary, weatherCode, weatherIcon,
|
||||
updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather
|
||||
}
|
||||
}
|
||||
74
frontend/src/pages/chat/composables/useDailyDigest.ts
Normal file
74
frontend/src/pages/chat/composables/useDailyDigest.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ref } from 'vue'
|
||||
import { getRecentDigests, getDueReminders, snoozeReminder, dismissReminder } from '@/api/memory'
|
||||
|
||||
function formatDateKey(date: Date) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function useDailyDigest() {
|
||||
const dailyDigest = ref<any>(null)
|
||||
const digestLoading = ref(false)
|
||||
const recentDigests = ref<any[]>([])
|
||||
const activeReminder = ref<any>(null)
|
||||
const reminderVisible = ref(false)
|
||||
let reminderPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadDailyDigest() {
|
||||
digestLoading.value = true
|
||||
try {
|
||||
const today = formatDateKey(new Date())
|
||||
const response = await getRecentDigests(6)
|
||||
const items = response.data?.items ?? []
|
||||
recentDigests.value = items
|
||||
dailyDigest.value = items.find((item: any) => item.date === today) ?? null
|
||||
} catch (err) {
|
||||
console.warn('Failed to load daily digest:', err)
|
||||
recentDigests.value = []
|
||||
dailyDigest.value = null
|
||||
} finally {
|
||||
digestLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pollDueReminders() {
|
||||
try {
|
||||
const response = await getDueReminders()
|
||||
if (response.data?.items?.length > 0) {
|
||||
activeReminder.value = response.data.items[0]
|
||||
reminderVisible.value = true
|
||||
} else {
|
||||
reminderVisible.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to poll due reminders:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSnooze(id: string, minutes: number) {
|
||||
try {
|
||||
await snoozeReminder(id, minutes)
|
||||
reminderVisible.value = false
|
||||
setTimeout(pollDueReminders, minutes * 60 * 1000)
|
||||
} catch (err) {
|
||||
console.warn('Failed to snooze reminder:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismiss(id: string) {
|
||||
try {
|
||||
await dismissReminder(id)
|
||||
reminderVisible.value = false
|
||||
} catch (err) {
|
||||
console.warn('Failed to dismiss reminder:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dailyDigest, digestLoading, recentDigests,
|
||||
activeReminder, reminderVisible, reminderPollTimer,
|
||||
loadDailyDigest, pollDueReminders, handleSnooze, handleDismiss
|
||||
}
|
||||
}
|
||||
194
frontend/src/pages/chat/composables/useSidebarPlan.ts
Normal file
194
frontend/src/pages/chat/composables/useSidebarPlan.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { CornerDownLeft, Database, Sparkles, Sun } from 'lucide-vue-next'
|
||||
import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter'
|
||||
|
||||
export interface SidebarFocusItem {
|
||||
id: string; label: string; title: string; meta: string; tone: 'done' | 'doing' | 'pending'
|
||||
}
|
||||
export interface SidebarNewsItem {
|
||||
id: string; title: string; meta: string
|
||||
}
|
||||
|
||||
export const sidebarCollapsedModules = [
|
||||
{ id: 'calendar', label: '日历', icon: Sun },
|
||||
{ id: 'status', label: '计划', icon: Database },
|
||||
{ id: 'focus', label: '重点', icon: Sparkles },
|
||||
{ id: 'review', label: '复盘', icon: CornerDownLeft },
|
||||
]
|
||||
|
||||
function formatDateKey(date: Date) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatMonthKey(date: Date) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${year}-${month}`
|
||||
}
|
||||
|
||||
export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void) {
|
||||
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
|
||||
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
|
||||
|
||||
const todayDateKey = computed(() => formatDateKey(clientTimeRef.value))
|
||||
const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item])))
|
||||
|
||||
const calendarCells = computed(() => {
|
||||
const year = clientTimeRef.value.getFullYear()
|
||||
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 }> = []
|
||||
for (let index = 0; index < firstDayOffset; index += 1) {
|
||||
cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: 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 })
|
||||
}
|
||||
while (cells.length % 7 !== 0) {
|
||||
cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false })
|
||||
}
|
||||
return cells
|
||||
})
|
||||
|
||||
const todayPlanCounters = computed(() => {
|
||||
const detail = todayPlanDetail.value
|
||||
if (!detail) return { done: 0, doing: 0, pending: 0, total: 0, completion: 0 }
|
||||
const todoDone = detail.todos.filter((item) => item.is_completed).length
|
||||
const todoPending = detail.todos.filter((item) => !item.is_completed).length
|
||||
const taskDone = detail.tasks.filter((item) => item.status === 'done').length
|
||||
const taskDoing = detail.tasks.filter((item) => item.status === 'in_progress').length
|
||||
const taskPending = detail.tasks.filter((item) => item.status === 'todo').length
|
||||
const goalDone = detail.goals.filter((item) => item.status === 'done').length
|
||||
const goalPending = detail.goals.filter((item) => item.status !== 'done').length
|
||||
const reminderDone = detail.reminders.filter((item) => item.status === 'done' || item.is_dismissed).length
|
||||
const reminderPending = detail.reminders.filter((item) => item.status !== 'done' && !item.is_dismissed).length
|
||||
const done = todoDone + taskDone + goalDone + reminderDone
|
||||
const doing = taskDoing
|
||||
const pending = todoPending + taskPending + goalPending + reminderPending
|
||||
const total = done + doing + pending
|
||||
return { done, doing, pending, total, completion: total > 0 ? Math.round((done / total) * 100) : 0 }
|
||||
})
|
||||
|
||||
const monthReviewStats = computed(() => monthPlanDays.value.reduce(
|
||||
(acc, item) => {
|
||||
acc.todoTotal += item.todo_total
|
||||
acc.todoCompleted += item.todo_completed
|
||||
acc.taskTotal += item.task_due_total
|
||||
acc.reminderTotal += item.reminder_total
|
||||
acc.goalTotal += item.goal_total
|
||||
acc.highPriorityTotal += item.high_priority_total
|
||||
if (item.todo_total + item.task_due_total + item.reminder_total + item.goal_total > 0) acc.activeDays += 1
|
||||
return acc
|
||||
},
|
||||
{ todoTotal: 0, todoCompleted: 0, taskTotal: 0, reminderTotal: 0, goalTotal: 0, highPriorityTotal: 0, activeDays: 0 },
|
||||
))
|
||||
|
||||
const sidebarWeekLabels = ['一', '二', '三', '四', '五', '六', '日']
|
||||
|
||||
const sidebarStatusHeadline = computed(() => (
|
||||
todayPlanCounters.value.total
|
||||
? `今日共 ${todayPlanCounters.value.total} 项计划,已完成 ${todayPlanCounters.value.done} 项`
|
||||
: '今日计划正在同步,稍后会显示最新状态'
|
||||
))
|
||||
|
||||
const sidebarStatusBreakdown = computed(() => [
|
||||
{ key: 'done', label: '已完成', value: todayPlanCounters.value.done, tone: 'done' },
|
||||
{ key: 'doing', label: '进行中', value: todayPlanCounters.value.doing, tone: 'doing' },
|
||||
{ key: 'pending', label: '未开始', value: todayPlanCounters.value.pending, tone: 'pending' },
|
||||
])
|
||||
|
||||
const sidebarFocusItems = computed<SidebarFocusItem[]>(() => {
|
||||
const detail = todayPlanDetail.value
|
||||
if (!detail) return []
|
||||
const goalItems = detail.goals.filter((goal) => goal.status !== 'done').map((goal) => ({
|
||||
id: `goal-${goal.id}`, label: '目标', title: goal.title, meta: goal.note || '今日目标推进', tone: 'doing' as const,
|
||||
}))
|
||||
const taskItems = detail.tasks.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
|
||||
.sort((a, b) => { const r = { urgent: 0, high: 1, medium: 2, low: 3 }; return r[a.priority] - r[b.priority] })
|
||||
.map((task) => ({
|
||||
id: `task-${task.id}`, label: task.priority === 'urgent' || task.priority === 'high' ? '高优任务' : '任务',
|
||||
title: task.title, meta: task.status === 'in_progress' ? '处理中' : '待启动',
|
||||
tone: task.status === 'in_progress' ? 'doing' as const : 'pending' as const,
|
||||
}))
|
||||
const reminderItems = detail.reminders.filter((r) => r.status !== 'done' && !r.is_dismissed)
|
||||
.map((r) => ({ id: `reminder-${r.id}`, label: '提醒', title: r.title, meta: r.reminder_at.slice(11, 16), tone: 'pending' as const }))
|
||||
const todoItems = detail.todos.filter((t) => !t.is_completed)
|
||||
.map((t) => ({ id: `todo-${t.id}`, label: '待办', title: t.title, meta: t.source === 'manual' ? '手动记录' : '系统同步', tone: 'pending' as const }))
|
||||
return [...goalItems, ...taskItems, ...reminderItems, ...todoItems].slice(0, 5)
|
||||
})
|
||||
|
||||
const sidebarReviewAchievements = computed(() => {
|
||||
const stats = monthReviewStats.value
|
||||
const items = [
|
||||
stats.todoCompleted > 0 ? `累计完成 ${stats.todoCompleted} 项待办,执行节啬已形成闭环。` : '',
|
||||
stats.activeDays > 0 ? `本月已有 ${stats.activeDays} 天产生有效计划记录,日程连贯性稳定。` : '',
|
||||
stats.highPriorityTotal > 0 ? `高优事项共 ${stats.highPriorityTotal} 项进行中,重点任务没有脱离视野。` : '',
|
||||
].filter(Boolean)
|
||||
if (items.length > 0) return items.slice(0, 3)
|
||||
return ['本月计划数据还在积累中,可以从今日重点开始逐步建立复盘样本。']
|
||||
})
|
||||
|
||||
const sidebarReviewReflections = computed(() => {
|
||||
const stats = monthReviewStats.value
|
||||
const pendingTodoCount = Math.max(stats.todoTotal - stats.todoCompleted, 0)
|
||||
const items = [
|
||||
pendingTodoCount > 0 ? `仍有 ${pendingTodoCount} 项待办未完成,建议拆成更短的收尾窗口。` : '',
|
||||
stats.highPriorityTotal >= 8 ? '高优事项密度偏高,最好提前锁定 1 到 2 个绝对优先级别。' : '',
|
||||
stats.reminderTotal >= Math.max(6, stats.activeDays) ? '提醒数量较多,说明执行中断点偏多,适合增加固定回固时段。' : '',
|
||||
].filter(Boolean)
|
||||
if (items.length > 0) return items.slice(0, 3)
|
||||
return ['本月节啬相对稳定,下一步可以把重点事项再收到更清晰的主线。']
|
||||
})
|
||||
|
||||
const sidebarFeedItems = computed<SidebarNewsItem[]>(() => [
|
||||
{ id: 'fallback-1', title: 'AI 研发节啬继续升温,模型与工作流一体化成为主溜话题。', meta: 'Industry' },
|
||||
{ id: 'fallback-2', title: '本地知识库与计划系统的联动体验,正在成为效率工具的新竞争点。', meta: 'Product' },
|
||||
{ id: 'fallback-3', title: '建议接入真实 RSS 源后替换当前占位卡片,以获得即时资讯流。', meta: 'System' },
|
||||
])
|
||||
|
||||
const topbarFeedItems = computed(() => sidebarFeedItems.value.length > 0 ? [...sidebarFeedItems.value, ...sidebarFeedItems.value] : [])
|
||||
|
||||
async function loadSidebarPlanSnapshot(date = new Date()) {
|
||||
const dateKey = formatDateKey(date)
|
||||
const monthKey = formatMonthKey(date)
|
||||
try {
|
||||
const [todayResponse, monthResponse] = await Promise.all([
|
||||
scheduleCenterApi.date(dateKey),
|
||||
scheduleCenterApi.month(monthKey),
|
||||
])
|
||||
todayPlanDetail.value = todayResponse.data
|
||||
monthPlanDays.value = monthResponse.data.days
|
||||
} catch (err) {
|
||||
console.warn('Failed to load sidebar plan snapshot:', err)
|
||||
todayPlanDetail.value = null
|
||||
monthPlanDays.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(todayDateKey, (next, previous) => {
|
||||
if (next === previous) return
|
||||
void loadDailyDigestFn()
|
||||
void loadSidebarPlanSnapshot(clientTimeRef.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadDailyDigestFn()
|
||||
void loadSidebarPlanSnapshot(new Date())
|
||||
})
|
||||
|
||||
return {
|
||||
todayPlanDetail, monthPlanDays, todayDateKey, monthPlanSummaryMap,
|
||||
calendarCells, todayPlanCounters, monthReviewStats,
|
||||
sidebarWeekLabels, sidebarStatusHeadline, sidebarStatusBreakdown,
|
||||
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
|
||||
sidebarFeedItems, topbarFeedItems, loadSidebarPlanSnapshot, sidebarCollapsedModules
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
2354
frontend/src/pages/chat/index_backup_sidebar.vue
Normal file
2354
frontend/src/pages/chat/index_backup_sidebar.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user