Compare commits

...

2 Commits

Author SHA1 Message Date
472528e708 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
2026-04-05 20:45:16 +08:00
e24092f3ab fix(chat): narrow left sidebar (332→280px) and add Chinese font fallbacks for mech aesthetic
Sidebar width reduced for denser layout. Font stacks updated to include Noto Sans SC and Microsoft YaHei fallbacks so Chinese text renders with consistent mech typography. Left sidebar elements (new-chat-btn, conv-title, empty-text, empty-hint) now explicitly use var(--font-display).
2026-04-05 20:37:46 +08:00
19 changed files with 6880 additions and 3798 deletions

View File

@@ -0,0 +1,193 @@
# Phase M.4对话自动学习Auto Memory Extraction
日期2026-04-05
状态:规划中
依赖M.1 (重要性评分)
工作量3 天
---
## 1. 本阶段目的
让 Jarvis 在每次对话结束后**自动**从对话内容中提取记忆,而不需要用户手动触发。
当前问题:
- `POST /brain/learn/run` 是手动触发,用户不会每次手动调
- 没有自动学习M.1 的评分系统、M.2 的遗忘系统都缺少输入
- 记忆库会随时间停滞,而不是随使用不断丰富
---
## 2. 核心架构
```
对话结束
ConversationEndHook
MemoryExtractor
├── extract_facts() # 事实:你住在北京、你用 Python
├── extract_preferences() # 偏好:你喜欢简短的回答
├── extract_goals() # 目标:你想学 Rust
├── extract_pain_points() # 痛点:反复问同一类问题
└── extract_events() # 事件:今天提到的重要事情
ImportanceScorer (M.1) # 评分后存入 UserMemory
去重检查 # 避免重复存储相似记忆
UserMemory / BrainMemory
```
---
## 3. 核心实现
### 3.1 MemoryExtractor
```python
class MemoryExtractor:
async def extract_from_conversation(
self,
user_id: str,
messages: list[Message],
) -> list[ExtractedMemory]:
"""
从一段对话中提取记忆条目。
调用 LLM 做结构化抽取,返回待存储的记忆列表。
"""
async def deduplicate(
self,
new_memories: list[ExtractedMemory],
existing_memories: list[UserMemory],
) -> list[ExtractedMemory]:
"""
与现有记忆做相似度对比,过滤重复项。
相似度 > 0.85 视为重复,更新而非新增。
"""
```
### 3.2 LLM 提取 Prompt结构化输出
```python
EXTRACT_PROMPT = """
从以下对话中提取用户的记忆信息,以 JSON 格式返回:
对话内容:
{conversation_text}
提取以下类型:
- fact: 关于用户的客观事实(职业、地点、技能等)
- preference: 用户的偏好和习惯
- goal: 用户提到的目标或计划
- pain_point: 反复出现或明显困扰用户的问题
- event: 今天发生的重要事件
输出格式:
[
{"type": "fact", "content": "...", "confidence": 0.9},
{"type": "goal", "content": "...", "confidence": 0.7}
]
只提取明确的信息,不要猜测。
"""
```
### 3.3 触发时机
```python
# 在 conversation router 的对话结束时异步触发
# routers/conversation.py
@router.post("/api/conversations/{conversation_id}/end")
async def end_conversation(conversation_id: str, ...):
# 原有逻辑...
# 异步触发记忆提取,不阻塞响应
background_tasks.add_task(
memory_extractor.extract_from_conversation,
user_id=current_user.id,
messages=messages,
)
```
也支持**会话超时自动触发**(超过 30 分钟无新消息视为对话结束):
```python
# scheduler_service.py
@scheduler.scheduled_task("interval", minutes=30)
async def check_idle_conversations():
"""检查闲置对话,触发记忆提取"""
```
---
## 4. 去重逻辑
```python
# 简单相似度检查(用 Mem0 自带的语义去重,或简单字符串匹配)
async def deduplicate(self, new_memory: ExtractedMemory, user_id: str) -> bool:
"""
返回 True 表示是新记忆False 表示已存在(更新原记忆即可)
"""
existing = await self.memory_service.search(
query=new_memory.content,
user_id=user_id,
top_k=3,
)
for mem in existing:
if similarity(mem.content, new_memory.content) > 0.85:
# 更新现有记忆的 frequency_count而非新建
await self.memory_service.reinforce(mem.id)
return False
return True
```
---
## 5. 核心文件
### 5.1 新增文件
| 文件 | 职责 |
|------|------|
| `services/memory/memory_extractor.py` | 对话记忆提取 |
| `tests/services/test_memory_extractor.py` | 提取测试 |
### 5.2 修改文件
| 文件 | 修改内容 |
|------|---------|
| `routers/conversation.py` | 对话结束时触发提取 |
| `services/scheduler_service.py` | 添加闲置对话检查 |
---
## 6. 验收标准
| 标准 | 说明 |
|------|------|
| 自动触发 | 对话结束后 30 秒内完成提取 |
| 提取准确 | fact/goal/pain_point 类型识别准确 |
| 去重有效 | 重复内容不新建,只强化原记忆 |
| 不阻塞对话 | 提取为后台任务,不影响响应速度 |
| 单元测试覆盖率 | > 80% |
---
## 7. 工作量估算
| 任务 | 工作量 |
|------|--------|
| MemoryExtractor 实现 | 1 天 |
| LLM Prompt 调优 | 0.5 天 |
| 去重逻辑 | 0.5 天 |
| 触发集成(对话结束 + 调度) | 0.5 天 |
| 测试 | 0.5 天 |
| **合计** | **3 天** |

View File

@@ -0,0 +1,228 @@
# Phase M.5记忆召回注入Memory Recall Injection
日期2026-04-05
状态:规划中
依赖M.1 (重要性评分), M.4 (自动提取)
工作量2 天
---
## 1. 本阶段目的
让 Jarvis 在每次对话时**自动**将相关记忆注入到 LLM 的 system prompt使 AI 真正「记得」用户。
当前问题:
- M.1-M.4 构建和管理了记忆,但 LLM 在生成回答时根本看不到这些记忆
- `memory_service.recall_memories()` 虽然存在,但没有在对话路由中被调用
- 记忆库有内容,对话却没有个性化——记忆和对话是两个孤立的系统
---
## 2. 核心架构
```
用户发来消息
MemoryRecallInjector
├── retrieve_relevant() # 语义搜索匹配当前消息
├── rank_by_importance() # 按 M.1 重要性分数排序
├── budget_tokens() # 控制注入 token 数量(上限 800
└── format_context() # 格式化为 system prompt 片段
LLM system prompt 中追加 memory context
LLM 生成回答(带个人化上下文)
触发 M.2 强化(召回的记忆 frequency +1
```
---
## 3. 核心实现
### 3.1 MemoryRecallInjector
```python
class MemoryRecallInjector:
async def build_context(
self,
user_id: str,
current_message: str,
token_budget: int = 800,
) -> str:
"""
根据当前消息,检索相关记忆并拼装为 system prompt 片段。
"""
# 1. 语义检索最相关的记忆
candidates = await self.memory_service.recall_memories(
user_id=user_id,
query=current_message,
top_k=20,
)
# 2. 过滤已归档记忆M.2 decay < 0.2 的记忆不注入)
active = [m for m in candidates if not m.is_archived]
# 3. 按重要性评分 + 相关性综合排序
ranked = self._rank(active, current_message)
# 4. Token 预算控制,避免占用过多上下文
selected = self._budget_select(ranked, token_budget)
# 5. 格式化
return self._format(selected)
def _format(self, memories: list[UserMemory]) -> str:
if not memories:
return ""
lines = ["[关于你的记忆]"]
for m in memories:
lines.append(f"- {m.content}")
return "\n".join(lines)
```
### 3.2 注入点:对话路由
```python
# routers/conversation.py
@router.post("/api/conversations/{conversation_id}/messages")
async def send_message(conversation_id: str, body: MessageRequest, ...):
# 1. 召回注入
memory_context = await memory_injector.build_context(
user_id=current_user.id,
current_message=body.content,
)
# 2. 拼装 system prompt
system_prompt = base_system_prompt
if memory_context:
system_prompt = f"{system_prompt}\n\n{memory_context}"
# 3. 发送给 LLM
response = await llm.chat(
messages=conversation_messages,
system=system_prompt,
)
# 4. 触发记忆强化(后台任务,不阻塞)
background_tasks.add_task(
memory_reinforcement.trigger_by_query,
user_id=current_user.id,
query=body.content,
)
return response
```
### 3.3 排序逻辑
```python
def _rank(
self,
memories: list[UserMemory],
query: str,
) -> list[UserMemory]:
"""
综合排序:语义相关性 × 重要性评分
- 重要性分数来自 M.1 ImportanceScorer
- 相关性分数来自向量距离mem0 已计算)
"""
def score(m: UserMemory) -> float:
relevance = m.similarity_score or 0.5 # 来自召回时的余弦相似度
importance = m.importance_score # 来自 M.1
recency_boost = 1.0 if m.memory_type in ("goal", "pain_point") else 0.8
return relevance * 0.6 + importance * 0.4 * recency_boost
return sorted(memories, key=score, reverse=True)
```
---
## 4. Token 预算控制
记忆注入不能无限制增长,否则会挤压对话本身的上下文空间。
```python
def _budget_select(
self,
memories: list[UserMemory],
token_budget: int,
) -> list[UserMemory]:
"""
贪心选择:按排名依次选入,直到 token 预算耗尽。
粗略估算1 条记忆 ≈ 30 token
"""
selected = []
used = 20 # "[关于你的记忆]\n" 的固定开销
for m in memories:
cost = len(m.content) // 2 + 10 # 粗略估算
if used + cost > token_budget:
break
selected.append(m)
used += cost
return selected
```
**默认预算800 token**(约 26 条记忆),可在 config 中调整。
---
## 5. 记忆类型优先级
不同类型的记忆注入优先级不同:
| 类型 | 优先级 | 说明 |
|------|--------|------|
| `pain_point` | 最高 | 反复困扰用户的问题,每次都应提醒 |
| `goal` | 高 | 用户的目标,影响回答方向 |
| `preference` | 中 | 影响回答风格(简短、代码优先等)|
| `fact` | 中 | 基础事实(职业、地点、技术栈)|
| `event` | 低 | 今日事件,时效性强,过期降权 |
---
## 6. 核心文件
### 6.1 新增文件
| 文件 | 职责 |
|------|------|
| `services/memory/recall_injector.py` | 记忆召回与注入 |
| `tests/services/test_recall_injector.py` | 注入测试 |
### 6.2 修改文件
| 文件 | 修改内容 |
|------|---------|
| `routers/conversation.py` | 发送消息前注入记忆 context |
| `services/memory_service.py` | recall_memories() 返回相似度分数 |
---
## 7. 验收标准
| 标准 | 说明 |
|------|------|
| 注入生效 | LLM 回答中能体现用户个人信息 |
| 不超 token 预算 | 注入内容 ≤ 800 token |
| 高优先级优先 | goal/pain_point 比 fact 更早注入 |
| 已归档不注入 | decay < 0.2 的记忆不出现在 context 中 |
| 不阻塞响应 | 注入耗时 < 100ms内存/向量检索) |
| 强化触发 | 被召回的记忆 frequency_count +1 |
---
## 8. 工作量估算
| 任务 | 工作量 |
|------|--------|
| MemoryRecallInjector 实现 | 0.5 天 |
| 对话路由集成 | 0.5 天 |
| Token 预算 + 排序调优 | 0.5 天 |
| 测试 | 0.5 天 |
| **合计** | **2 天** |

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>

File diff suppressed because it is too large Load Diff

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>

View File

@@ -22,8 +22,8 @@
--text-secondary: #7eb8c9;
--text-dim: #3d6b7a;
--text-muted: #1e3d4a;
--font-display: 'Orbitron', 'Share Tech Mono', monospace;
--font-mono: 'JetBrains Mono', 'Share Tech Mono', monospace;
--font-display: 'Orbitron', 'Share Tech Mono', 'Noto Sans SC', 'Microsoft YaHei', monospace;
--font-mono: 'JetBrains Mono', 'Share Tech Mono', 'Noto Sans SC', 'Microsoft YaHei', monospace;
--font-body: 'JetBrains Mono', monospace;
--radius-sm: 4px;
--radius-md: 8px;