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:
2026-04-05 20:45:16 +08:00
parent e24092f3ab
commit 472528e708
18 changed files with 4435 additions and 3796 deletions

38
frontend/src/api/memory.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
export interface DailyDigestKeyPoint {
content: string
source: string
}
export interface DailyDigestSuggestion {
text: string
type?: 'action' | 'info' | 'warning'
}
export interface DailyDigestItem {
date: string
summary: string
keyPoints: DailyDigestKeyPoint[]
suggestions: DailyDigestSuggestion[]
}
export interface DailyDigestListResponse {
items: DailyDigestItem[]
}
export interface DueReminderItem {
id: string
content?: string
title?: string
trigger_at?: string
}
export interface DueReminderListResponse {
items: DueReminderItem[]
}
export function getDailyDigest(date: string): Promise<{ data: DailyDigestItem }>
export function getRecentDigests(limit?: number): Promise<{ data: DailyDigestListResponse }>
export function createReminder(content: string, triggerAt: string, triggerType?: string): Promise<unknown>
export function snoozeReminder(id: string, minutes: number): Promise<unknown>
export function dismissReminder(id: string): Promise<unknown>
export function getDueReminders(): Promise<{ data: DueReminderListResponse }>

View File

@@ -0,0 +1,37 @@
import api from './index'
/**
* Daily Digest API
*/
export async function getDailyDigest(date) {
return api.get(`/api/memory/daily-digest/${date}`)
}
export async function getRecentDigests(limit = 7) {
return api.get('/api/memory/daily-digests', { params: { limit } })
}
/**
* Reminder API
*/
export async function createReminder(content, triggerAt, triggerType = 'time') {
return api.post('/api/memory/reminders', {
content,
trigger_at: triggerAt,
trigger_type: triggerType,
})
}
export async function snoozeReminder(id, minutes) {
return api.post(`/api/memory/reminders/${id}/snooze`, { minutes })
}
export async function dismissReminder(id) {
return api.delete(`/api/memory/reminders/${id}`)
}
export async function getDueReminders() {
return api.get('/api/memory/reminders/due')
}

View File

@@ -63,6 +63,16 @@ const appChildren: RouteRecordRaw[] = [
name: 'code-commander',
component: () => import('@/pages/chat/CodeCommander.vue'),
},
{
path: 'temple',
name: 'temple',
component: () => import('@/pages/temple/index.vue'),
},
{
path: 'war-room',
name: 'war-room',
component: () => import('@/pages/war-room/index.vue'),
},
]
export const routes: RouteRecordRaw[] = [

View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
import { Calendar } from 'lucide-vue-next'
export interface KeyPoint {
content: string
source: string
}
export interface Suggestion {
text: string
type?: 'action' | 'info' | 'warning'
}
export interface DailyDigestData {
date: string
summary: string
keyPoints: KeyPoint[]
suggestions: Suggestion[]
}
defineProps<{
digest?: DailyDigestData | null
loading?: boolean
}>()
</script>
<template>
<div v-if="loading" class="digest-card digest-loading">
<div class="digest-skeleton header-skeleton"></div>
<div class="digest-skeleton text-skeleton"></div>
<div class="digest-skeleton text-skeleton short"></div>
</div>
<div v-else-if="digest" class="digest-card">
<div class="digest-header">
<div class="digest-date">
<Calendar :size="14" class="date-icon" />
<span class="date-label">{{ digest.date }}</span>
</div>
<div class="digest-badge">每日摘要</div>
</div>
<div class="digest-summary">
{{ digest.summary }}
</div>
<div v-if="digest.keyPoints.length > 0" class="digest-section">
<div class="section-label">KEY_INSIGHTS</div>
<ul class="key-points-list">
<li v-for="(point, index) in digest.keyPoints" :key="index" class="key-point-item">
<span class="point-bullet">{{ String(index + 1).padStart(2, '0') }}</span>
<div class="point-content">
<span class="point-text">{{ point.content }}</span>
<span v-if="point.source" class="point-source">{{ point.source }}</span>
</div>
</li>
</ul>
</div>
<div v-if="digest.suggestions.length > 0" class="digest-section">
<div class="section-label">SUGGESTIONS</div>
<div class="suggestions-list">
<div
v-for="(suggestion, index) in digest.suggestions"
:key="index"
class="suggestion-item"
:class="suggestion.type || 'info'"
>
<span class="suggestion-marker">{{ suggestion.type === 'action' ? '→' : suggestion.type === 'warning' ? '!' : '●' }}</span>
<span class="suggestion-text">{{ suggestion.text }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.digest-card {
margin: 16px 20px;
padding: 20px;
background: linear-gradient(135deg, rgba(123, 44, 191, 0.15) 0%, rgba(59, 130, 246, 0.12) 50%, rgba(123, 44, 191, 0.08) 100%);
border: 1px solid rgba(123, 44, 191, 0.25);
border-radius: var(--radius-lg);
position: relative;
overflow: hidden;
animation: fade-in-up 0.4s ease-out;
}
.digest-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(123, 44, 191, 0.5), rgba(59, 130, 246, 0.5), transparent);
}
.digest-card::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgba(123, 44, 191, 0.1), transparent 50%);
pointer-events: none;
}
.digest-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
position: relative;
z-index: 1;
}
.digest-date {
display: flex;
align-items: center;
gap: 8px;
}
.date-icon {
color: rgba(123, 44, 191, 0.8);
}
.date-label {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
letter-spacing: 0.05em;
}
.digest-badge {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
padding: 4px 10px;
border-radius: 4px;
background: linear-gradient(135deg, rgba(123, 44, 191, 0.3), rgba(59, 130, 246, 0.25));
border: 1px solid rgba(123, 44, 191, 0.4);
color: #c4b5fd;
text-shadow: 0 0 10px rgba(123, 44, 191, 0.5);
}
.digest-summary {
font-family: var(--font-body);
font-size: 13px;
line-height: 1.7;
color: var(--text-primary);
margin-bottom: 20px;
position: relative;
z-index: 1;
}
.digest-section {
margin-top: 16px;
position: relative;
z-index: 1;
}
.section-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: rgba(123, 44, 191, 0.7);
margin-bottom: 12px;
}
.key-points-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.key-point-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: var(--radius-sm);
border: 1px solid rgba(123, 44, 191, 0.15);
}
.point-bullet {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: rgba(123, 44, 191, 0.9);
text-shadow: 0 0 8px rgba(123, 44, 191, 0.5);
flex-shrink: 0;
width: 20px;
}
.point-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.point-text {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-primary);
line-height: 1.5;
}
.point-source {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.03em;
}
.suggestions-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.suggestion-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.15);
border-radius: var(--radius-sm);
border-left: 2px solid;
}
.suggestion-item.info {
border-left-color: rgba(59, 130, 246, 0.6);
}
.suggestion-item.action {
border-left-color: rgba(0, 245, 212, 0.6);
}
.suggestion-item.warning {
border-left-color: rgba(249, 168, 37, 0.6);
}
.suggestion-marker {
font-family: var(--font-mono);
font-size: 11px;
flex-shrink: 0;
}
.suggestion-item.info .suggestion-marker {
color: rgba(59, 130, 246, 0.9);
}
.suggestion-item.action .suggestion-marker {
color: rgba(0, 245, 212, 0.9);
}
.suggestion-item.warning .suggestion-marker {
color: rgba(249, 168, 37, 0.9);
}
.suggestion-text {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
/* Loading skeleton */
.digest-loading {
min-height: 180px;
}
.digest-skeleton {
background: linear-gradient(90deg, rgba(123, 44, 191, 0.1) 25%, rgba(123, 44, 191, 0.2) 50%, rgba(123, 44, 191, 0.1) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
margin-bottom: 12px;
}
.header-skeleton {
height: 24px;
width: 60%;
}
.text-skeleton {
height: 16px;
width: 100%;
}
.text-skeleton.short {
width: 75%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>

View File

@@ -0,0 +1,224 @@
<script setup lang="ts">
import { Bell, Clock } from 'lucide-vue-next'
export interface ActiveReminder {
id: string
content: string
triggerAt: string
triggerType: 'time' | 'context'
}
const props = defineProps<{
reminder: ActiveReminder | null
visible: boolean
}>()
const emit = defineEmits<{
(e: 'snooze', id: string, minutes: number): void
(e: 'dismiss', id: string): void
}>()
function handleSnooze() {
if (props.reminder) {
emit('snooze', props.reminder.id, 15)
}
}
function handleDismiss() {
if (props.reminder) {
emit('dismiss', props.reminder.id)
}
}
function formatTime(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
</script>
<template>
<Transition name="slide-up">
<div v-if="visible && reminder" class="reminder-toast">
<div class="toast-icon">
<Bell :size="20" />
</div>
<div class="toast-content">
<div class="toast-header">
<span class="toast-label">REMINDER</span>
<span class="toast-time">
<Clock :size="10" />
{{ formatTime(reminder.triggerAt) }}
</span>
</div>
<div class="toast-message">{{ reminder.content }}</div>
</div>
<div class="toast-actions">
<button class="toast-btn snooze" @click="handleSnooze">
稍后
</button>
<button class="toast-btn dismiss" @click="handleDismiss">
知道了
</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.reminder-toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1000;
display: flex;
align-items: flex-start;
gap: 14px;
padding: 16px 18px;
min-width: 320px;
max-width: 420px;
background: linear-gradient(135deg, rgba(13, 21, 37, 0.98) 0%, rgba(10, 15, 26, 0.96) 100%);
border: 1px solid rgba(249, 168, 37, 0.3);
border-radius: var(--radius-lg);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 20px rgba(249, 168, 37, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.reminder-toast::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(249, 168, 37, 0.6), rgba(249, 168, 37, 0.8), rgba(249, 168, 37, 0.6), transparent);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.toast-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: rgba(249, 168, 37, 0.15);
color: var(--accent-amber);
flex-shrink: 0;
animation: pulse-glow-amber 2s ease-in-out infinite;
}
@keyframes pulse-glow-amber {
0%, 100% {
box-shadow: 0 0 8px rgba(249, 168, 37, 0.3);
}
50% {
box-shadow: 0 0 16px rgba(249, 168, 37, 0.5);
}
}
.toast-content {
flex: 1;
min-width: 0;
}
.toast-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.toast-label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--accent-amber);
}
.toast-time {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
}
.toast-message {
font-family: var(--font-body);
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
}
.toast-actions {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.toast-btn {
padding: 8px 14px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
border-radius: var(--radius-sm);
border: 1px solid;
transition: all var(--transition-fast);
white-space: nowrap;
}
.toast-btn.snooze {
background: rgba(0, 245, 212, 0.08);
border-color: rgba(0, 245, 212, 0.25);
color: var(--accent-cyan);
}
.toast-btn.snooze:hover {
background: rgba(0, 245, 212, 0.15);
border-color: rgba(0, 245, 212, 0.4);
box-shadow: 0 0 12px rgba(0, 245, 212, 0.2);
}
.toast-btn.dismiss {
background: rgba(249, 168, 37, 0.1);
border-color: rgba(249, 168, 37, 0.3);
color: var(--accent-amber);
}
.toast-btn.dismiss:hover {
background: rgba(249, 168, 37, 0.2);
border-color: rgba(249, 168, 37, 0.5);
box-shadow: 0 0 12px rgba(249, 168, 37, 0.25);
}
/* Slide-up animation */
.slide-up-enter-active {
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.slide-up-leave-active {
transition: all 0.25s ease-in;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(40px) scale(0.95);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
</style>

View File

@@ -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;

View 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}&current=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
}
}

View 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
}
}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,284 +1,46 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { logApi, type Log, type LogQueryParams, type LogStats } from '@/api/log'
import { Terminal, RefreshCw, Bot, MessageSquare, Settings, AlertCircle, ChevronDown, ChevronRight } from 'lucide-vue-next'
import { useLogsView } from './composables/useLogsView'
type TimePreset = '1h' | '6h' | '24h' | '7d' | 'custom'
interface LogFilters {
preset: TimePreset
start_at: string
end_at: string
request_id: string
route: string
operation: string
status_code: string
log_type: string
level: string
page: number
page_size: number
}
const logs = ref<Log[]>([])
const stats = ref<LogStats | null>(null)
const loading = ref(false)
const errorMessage = ref('')
const expandedLogId = ref<string | null>(null)
const autoRefresh = ref(false)
let refreshInterval: ReturnType<typeof window.setInterval> | null = null
const pageSizeOptions = [20, 50, 100]
const timePresets: Array<{ value: TimePreset; label: string }> = [
{ value: '1h', label: '1h' },
{ value: '6h', label: '6h' },
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: 'custom', label: '自定义' },
]
const logTypes = [
{ value: '', label: '全部', icon: null },
{ value: 'agent', label: '智能体', icon: Bot },
{ value: 'system', label: '系统', icon: Settings },
{ value: 'chat', label: '问答', icon: MessageSquare },
]
const logLevels = [
{ value: '', label: '全部' },
{ value: 'debug', label: '调试' },
{ value: 'info', label: '信息' },
{ value: 'warning', label: '警告' },
{ value: 'error', label: '错误' },
]
const statusCodeOptions = [
{ value: '', label: '全部状态' },
{ value: '200', label: '200' },
{ value: '400', label: '400' },
{ value: '401', label: '401' },
{ value: '404', label: '404' },
{ value: '422', label: '422' },
{ value: '500', label: '500' },
]
const levelColors: Record<string, string> = {
debug: 'var(--text-dim)',
info: 'var(--accent-cyan)',
warning: 'var(--accent-amber)',
error: 'var(--accent-red)',
}
const typeLabels: Record<string, string> = {
agent: '智能体',
system: '系统',
chat: '问答',
}
function toDatetimeLocalValue(date: Date): string {
const offsetMs = date.getTimezoneOffset() * 60 * 1000
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
}
function getPresetRange(preset: Exclude<TimePreset, 'custom'>) {
const end = new Date()
const start = new Date(end)
const hours = preset === '1h' ? 1 : preset === '6h' ? 6 : preset === '24h' ? 24 : 24 * 7
start.setHours(start.getHours() - hours)
return {
start_at: toDatetimeLocalValue(start),
end_at: toDatetimeLocalValue(end),
}
}
function createDefaultFilters(): LogFilters {
return {
preset: '24h',
...getPresetRange('24h'),
request_id: '',
route: '',
operation: '',
status_code: '',
log_type: '',
level: '',
page: 1,
page_size: 50,
}
}
const filters = ref<LogFilters>(createDefaultFilters())
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / filters.value.page_size)))
const visiblePageNumbers = computed(() => {
const totalPageCount = totalPages.value
const currentPage = filters.value.page
if (totalPageCount <= 5) {
return Array.from({ length: totalPageCount }, (_, index) => index + 1)
}
let startPage = Math.max(1, currentPage - 2)
let endPage = Math.min(totalPageCount, startPage + 4)
if (endPage - startPage < 4) {
startPage = Math.max(1, endPage - 4)
}
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index)
})
const total = ref(0)
function formatTime(dateStr: string | null): string {
if (!dateStr) return '-'
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return dateStr
return d.toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
month: '2-digit',
day: '2-digit',
})
}
function formatDuration(ms: number | null): string {
if (ms == null) return ''
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function formatDetails(details: Record<string, unknown> | null): string {
if (!details) return ''
return JSON.stringify(details, null, 2)
}
function toggleDetails(logId: string) {
expandedLogId.value = expandedLogId.value === logId ? null : logId
}
function normalizeFilters(): LogQueryParams {
return {
log_type: filters.value.log_type || undefined,
level: filters.value.level || undefined,
request_id: filters.value.request_id.trim() || undefined,
route: filters.value.route.trim() || undefined,
operation: filters.value.operation.trim() || undefined,
status_code: filters.value.status_code ? Number(filters.value.status_code) : undefined,
start_at: filters.value.start_at ? new Date(filters.value.start_at).toISOString() : undefined,
end_at: filters.value.end_at ? new Date(filters.value.end_at).toISOString() : undefined,
page: filters.value.page,
page_size: filters.value.page_size,
}
}
function validateDateRange() {
if (filters.value.start_at && filters.value.end_at && filters.value.start_at > filters.value.end_at) {
errorMessage.value = '开始时间不能晚于结束时间'
return false
}
return true
}
async function fetchLogs() {
loading.value = true
errorMessage.value = ''
try {
const requestedPage = filters.value.page
const res = await logApi.list(normalizeFilters())
logs.value = res.data.logs
total.value = res.data.total
const maxPage = Math.max(1, Math.ceil(res.data.total / filters.value.page_size))
if (requestedPage > maxPage) {
filters.value.page = maxPage
if (maxPage !== requestedPage) {
await fetchLogs()
}
return
}
} catch (e: unknown) {
errorMessage.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '加载日志失败'
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const res = await logApi.getStats(normalizeFilters())
stats.value = res.data
} catch {
if (!errorMessage.value) {
errorMessage.value = '加载日志统计失败'
}
}
}
async function loadData() {
if (!validateDateRange()) return
await Promise.all([fetchLogs(), fetchStats()])
}
function applyFilters() {
filters.value.page = 1
loadData()
}
function resetFilters() {
filters.value = createDefaultFilters()
loadData()
}
function applyPreset(preset: TimePreset) {
filters.value.preset = preset
if (preset !== 'custom') {
const range = getPresetRange(preset)
filters.value.start_at = range.start_at
filters.value.end_at = range.end_at
}
}
function markCustomRange() {
filters.value.preset = 'custom'
}
function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) {
refreshInterval = window.setInterval(() => {
loadData()
}, 5000)
} else if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value || page === filters.value.page) {
return
}
filters.value.page = page
fetchLogs()
}
function prevPage() {
goToPage(filters.value.page - 1)
}
function nextPage() {
goToPage(filters.value.page + 1)
}
function changePageSize(size: number) {
filters.value.page_size = size
filters.value.page = 1
loadData()
}
onMounted(loadData)
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
const {
Terminal,
RefreshCw,
Bot,
MessageSquare,
Settings,
AlertCircle,
ChevronDown,
ChevronRight,
logs,
stats,
loading,
errorMessage,
expandedLogId,
autoRefresh,
filters,
total,
totalPages,
visiblePageNumbers,
timePresets,
logTypes,
logLevels,
statusCodeOptions,
levelColors,
typeLabels,
formatTime,
formatDuration,
formatDetails,
toggleDetails,
applyPreset,
markCustomRange,
toggleAutoRefresh,
applyFilters,
resetFilters,
goToPage,
prevPage,
nextPage,
changePageSize,
loadData,
} = useLogsView()
</script>
<template>
@@ -415,346 +177,11 @@ onUnmounted(() => {
</button>
<button class="page-btn" :disabled="filters.page >= totalPages" @click="nextPage">下一页</button>
<select class="filter-select page-size-select" :value="filters.page_size" @change="changePageSize(Number(($event.target as HTMLSelectElement).value))">
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}/</option>
<option v-for="size in [20, 50, 100]" :key="size" :value="size">{{ size }}/</option>
</select>
</div>
</div>
</div>
</template>
<style scoped>
.log-view {
height: 100%;
display: flex;
flex-direction: column;
padding: 24px;
background: var(--bg-void);
}
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
}
.header-actions {
display: flex;
gap: 8px;
}
.btn-icon {
padding: 8px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-icon:hover, .btn-icon.active {
background: var(--accent-cyan-dim);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.btn-icon .spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.stats-overview {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
flex: 1;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 12px 16px;
text-align: center;
}
.stat-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 4px;
}
.stat-value {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.stat-value.cyan { color: var(--accent-cyan); }
.stat-value.purple { color: var(--accent-purple); }
.stat-value.amber { color: var(--accent-amber); }
.stat-value.red { color: var(--accent-red); }
.filters {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
}
.toolbar-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.filter-btn,
.filter-input,
.filter-select,
.action-btn,
.page-btn {
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
transition: all var(--transition-fast);
}
.filter-btn,
.action-btn,
.page-btn {
padding: 6px 12px;
cursor: pointer;
}
.filter-btn.active,
.filter-btn:hover,
.action-btn:hover,
.page-btn:hover:not(:disabled),
.page-btn.active,
.filter-input:focus,
.filter-select:focus {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
outline: none;
}
.filter-btn.active,
.action-btn.primary,
.page-btn.active {
background: var(--accent-cyan-dim);
}
.filter-input,
.filter-select {
min-width: 120px;
padding: 7px 10px;
}
.filter-input {
color-scheme: dark;
}
.action-btn {
background: var(--bg-void);
}
.log-list {
flex: 1;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.log-items {
padding: 8px;
}
.log-item {
border-bottom: 1px solid var(--border-dim);
transition: background var(--transition-fast);
}
.log-summary {
padding: 12px;
cursor: pointer;
}
.log-item:last-child {
border-bottom: none;
}
.log-item:hover {
background: rgba(0, 245, 212, 0.02);
}
.log-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.log-type {
font-family: var(--font-mono);
font-size: 9px;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
}
.log-type.agent {
background: rgba(0, 245, 212, 0.1);
color: var(--accent-cyan);
}
.log-type.system {
background: rgba(168, 85, 247, 0.1);
color: var(--accent-purple);
}
.log-type.chat {
background: rgba(249, 168, 37, 0.1);
color: var(--accent-amber);
}
.log-level {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
}
.log-duration {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
}
.log-message {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 6px;
}
.log-footer {
display: flex;
justify-content: space-between;
gap: 12px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
}
.log-status,
.log-operation,
.log-expand {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
}
.log-details {
padding: 0 12px 12px;
border-top: 1px solid var(--border-dim);
background: rgba(255, 255, 255, 0.02);
}
.detail-row {
margin-top: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.detail-json {
margin-top: 10px;
padding: 10px;
background: var(--bg-void);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
overflow-x: auto;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-primary);
white-space: pre-wrap;
}
.error-state {
color: var(--accent-red);
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-top: 16px;
}
.page-summary {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.page-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.page-number {
min-width: 36px;
padding-inline: 0;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-size-select {
min-width: 90px;
}
</style>
<style scoped src="./logsPage.css"></style>

View File

@@ -251,383 +251,5 @@ const {
</div>
</template>
<style scoped>
.settings-view {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.view-header {
display: flex;
align-items: center;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
position: relative;
z-index: 10;
}
.header-title {
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
/* Toast */
.toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 12px;
z-index: 1000;
animation: slide-in 0.3s ease;
}
.toast.success {
background: rgba(0, 245, 212, 0.15);
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
}
.toast.error {
background: rgba(255, 71, 87, 0.15);
border: 1px solid var(--accent-red);
color: var(--accent-red);
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* Loading */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,8,16,0.8);
z-index: 50;
}
.loading-text {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
animation: pulse 1s ease-in-out infinite;
}
/* Content */
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
z-index: 1;
}
/* Card */
.settings-card {
background: rgba(13,21,37,0.9);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 20px;
backdrop-filter: blur(12px);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
}
.reset-btn, .add-btn, .test-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
transition: all var(--transition-fast);
}
.reset-btn:hover, .add-btn:hover, .test-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.add-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.test-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
/* LLM Type Section */
.llm-type-section {
margin-bottom: 20px;
}
.llm-type-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.llm-type-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--accent-cyan);
}
.optional-tag {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.required-tag {
font-size: 9px;
color: var(--accent-red);
letter-spacing: 0.1em;
}
/* Warning Bar */
.warning-bar {
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 16px;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
/* Form */
.form-group {
margin-bottom: 14px;
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
.form-label {
display: block;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 6px;
}
.form-input, .form-select {
width: 100%;
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
transition: all var(--transition-fast);
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1);
}
.form-input.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-select {
cursor: pointer;
}
.input-with-toggle {
display: flex;
gap: 8px;
}
.input-with-toggle .form-input {
flex: 1;
}
.toggle-visibility {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.toggle-visibility:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Toggle */
.toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-btn {
width: 44px;
height: 22px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: 11px;
padding: 2px;
cursor: pointer;
transition: all 0.25s;
}
.toggle-btn.active {
background: rgba(0,245,212,.15);
border-color: var(--accent-cyan);
}
.toggle-knob {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
transition: all 0.25s;
}
.toggle-btn.active .toggle-knob {
background: var(--accent-cyan);
box-shadow: 0 0 8px var(--accent-cyan);
transform: translateX(22px);
}
/* Save Button */
.save-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-sm);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: 8px;
}
.save-btn:hover:not(:disabled) {
background: rgba(0,245,212,0.15);
box-shadow: 0 0 12px rgba(0,245,212,0.2);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.save-btn.full-width {
margin-top: 0;
}
.btn-loader {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
<style scoped src="./settingsPage.css">
</style>

View File

@@ -0,0 +1,378 @@
.settings-view {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.view-header {
display: flex;
align-items: center;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
position: relative;
z-index: 10;
}
.header-title {
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
/* Toast */
.toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 12px;
z-index: 1000;
animation: slide-in 0.3s ease;
}
.toast.success {
background: rgba(0, 245, 212, 0.15);
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
}
.toast.error {
background: rgba(255, 71, 87, 0.15);
border: 1px solid var(--accent-red);
color: var(--accent-red);
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* Loading */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,8,16,0.8);
z-index: 50;
}
.loading-text {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
animation: pulse 1s ease-in-out infinite;
}
/* Content */
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
z-index: 1;
}
/* Card */
.settings-card {
background: rgba(13,21,37,0.9);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 20px;
backdrop-filter: blur(12px);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
}
.reset-btn, .add-btn, .test-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
transition: all var(--transition-fast);
}
.reset-btn:hover, .add-btn:hover, .test-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.add-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.test-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
/* LLM Type Section */
.llm-type-section {
margin-bottom: 20px;
}
.llm-type-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.llm-type-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--accent-cyan);
}
.optional-tag {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.required-tag {
font-size: 9px;
color: var(--accent-red);
letter-spacing: 0.1em;
}
/* Warning Bar */
.warning-bar {
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 16px;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
/* Form */
.form-group {
margin-bottom: 14px;
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
.form-label {
display: block;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 6px;
}
.form-input, .form-select {
width: 100%;
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
transition: all var(--transition-fast);
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1);
}
.form-input.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-select {
cursor: pointer;
}
.input-with-toggle {
display: flex;
gap: 8px;
}
.input-with-toggle .form-input {
flex: 1;
}
.toggle-visibility {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.toggle-visibility:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Toggle */
.toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-btn {
width: 44px;
height: 22px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: 11px;
padding: 2px;
cursor: pointer;
transition: all 0.25s;
}
.toggle-btn.active {
background: rgba(0,245,212,.15);
border-color: var(--accent-cyan);
}
.toggle-knob {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
transition: all 0.25s;
}
.toggle-btn.active .toggle-knob {
background: var(--accent-cyan);
box-shadow: 0 0 8px var(--accent-cyan);
transform: translateX(22px);
}
/* Save Button */
.save-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-sm);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: 8px;
}
.save-btn:hover:not(:disabled) {
background: rgba(0,245,212,0.15);
box-shadow: 0 0 12px rgba(0,245,212,0.2);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.save-btn.full-width {
margin-top: 0;
}
.btn-loader {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
// 智慧神殿 - Temple of Wisdom
</script>
<template>
<div class="temple-page">
<div class="page-header">
<h1> 智慧神殿</h1>
<p class="subtitle">深邃智慧永恒传承</p>
</div>
<div class="page-content">
<div class="placeholder-content">
<div class="temple-icon">🏛</div>
<p>智慧神殿 - 敬请期待</p>
</div>
</div>
</div>
</template>
<style scoped>
.temple-page {
padding: 24px;
min-height: 100vh;
background: var(--bg-primary);
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-header h1 {
font-size: 28px;
color: var(--text-primary);
margin-bottom: 8px;
}
.subtitle {
color: var(--text-secondary);
font-size: 14px;
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
color: var(--text-secondary);
}
.temple-icon {
font-size: 64px;
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
// 战情室 - War Room
</script>
<template>
<div class="war-room-page">
<div class="page-header">
<h1>🗺 战情室</h1>
<p class="subtitle">运筹帷幄决胜千里</p>
</div>
<div class="page-content">
<div class="placeholder-content">
<div class="war-icon"></div>
<p>战情室 - 敬请期待</p>
</div>
</div>
</div>
</template>
<style scoped>
.war-room-page {
padding: 24px;
min-height: 100vh;
background: var(--bg-primary);
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-header h1 {
font-size: 28px;
color: var(--text-primary);
margin-bottom: 8px;
}
.subtitle {
color: var(--text-secondary);
font-size: 14px;
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
color: var(--text-secondary);
}
.war-icon {
font-size: 64px;
margin-bottom: 16px;
}
</style>