Files
JARVIS/frontend/src/pages/todo/index.vue
DESKTOP-72TV0V4\caoxiaozhu b024a2bcb5 refactor(frontend): move views into app and pages structure
Reorganize the frontend around app-level routing and page modules so the runtime and feature screens share a clearer navigation and composition layout for future work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:13:12 +08:00

420 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { todoApi, type Todo } from '@/api/todo'
import { CheckSquare, Plus, Sparkles, Calendar } from 'lucide-vue-next'
import { animate } from 'motion'
// 状态
const selectedDate = ref(new Date().toISOString().slice(0, 10)) // YYYY-MM-DD
const todos = ref<Todo[]>([])
const loading = ref(false)
const generating = ref(false)
const newTitle = ref('')
const isToday = computed(() => selectedDate.value === new Date().toISOString().slice(0, 10))
// 日期快捷切换
function formatDate(date: Date) {
return date.toISOString().slice(0, 10)
}
function goToday() {
selectedDate.value = formatDate(new Date())
}
function goYesterday() {
const d = new Date()
d.setDate(d.getDate() - 1)
selectedDate.value = formatDate(d)
}
function goBeforeYesterday() {
const d = new Date()
d.setDate(d.getDate() - 2)
selectedDate.value = formatDate(d)
}
// 加载数据
async function loadTodos() {
loading.value = true
try {
const res = await todoApi.list(selectedDate.value)
todos.value = res.data.items
} catch (e) {
console.error('加载待办失败', e)
} finally {
loading.value = false
}
}
// 新增
async function addTodo() {
if (!newTitle.value.trim()) return
try {
const res = await todoApi.create(newTitle.value.trim())
todos.value.unshift(res.data)
newTitle.value = ''
} catch (e) {
console.error('创建待办失败', e)
}
}
// 切换完成状态
async function toggleComplete(todo: Todo) {
if (!isToday.value) return
try {
const res = await todoApi.update(todo.id, { is_completed: !todo.is_completed })
const idx = todos.value.findIndex(t => t.id === todo.id)
if (idx !== -1) {
todos.value[idx] = res.data
// 播放动画
const el = document.querySelector(`[data-todo-id="${todo.id}"]`)
if (el) {
animate(el, { opacity: [0.5, 1] }, { duration: 0.3 }).play()
}
}
} catch (e) {
console.error('更新待办失败', e)
}
}
// 删除
async function deleteTodo(id: string) {
if (!isToday.value) return
try {
await todoApi.delete(id)
todos.value = todos.value.filter(t => t.id !== id)
} catch (e) {
console.error('删除待办失败', e)
}
}
// AI 生成
async function aiGenerate() {
generating.value = true
try {
const res = await todoApi.aiGenerate()
todos.value = res.data.items
} catch (e) {
console.error('AI 生成失败', e)
} finally {
generating.value = false
}
}
// 监听日期变化
watch(selectedDate, () => {
loadTodos()
})
onMounted(loadTodos)
</script>
<template>
<div class="todo-view scanlines">
<!-- 背景 -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<!-- Header -->
<div class="view-header">
<div class="header-title">
<CheckSquare :size="16" />
<span class="title-bracket">[</span>
<span class="title-text">DAILY TODO</span>
<span class="title-bracket">]</span>
</div>
<div class="header-actions">
<div v-if="isToday" class="ai-btn" @click="aiGenerate" :class="{ loading: generating }">
<Sparkles :size="14" :class="{ 'ai-spin': generating }" />
<span>{{ generating ? '生成中...' : 'AI 规划今日' }}</span>
</div>
</div>
</div>
<!-- 日期导航 -->
<div class="date-nav">
<button class="date-btn" :class="{ active: !isToday }" @click="goBeforeYesterday">前天</button>
<button class="date-btn" @click="goYesterday">昨天</button>
<button class="date-btn primary" :class="{ active: isToday }" @click="goToday">
今天
<Calendar :size="12" />
</button>
</div>
<!-- 主内容 -->
<div class="todo-content">
<!-- 今日新增输入框 -->
<div v-if="isToday" class="add-form">
<input
v-model="newTitle"
class="add-input"
placeholder="输入待办事项,按回车添加..."
@keyup.enter="addTodo"
/>
<button class="add-btn" @click="addTodo">
<Plus :size="16" />
</button>
</div>
<!-- 待办列表 -->
<div class="todo-list">
<div
v-for="todo in todos"
:key="todo.id"
:data-todo-id="todo.id"
class="todo-item"
:class="{ completed: todo.is_completed, 'ai-source': todo.source !== 'manual' }"
>
<button class="check-btn" @click="toggleComplete(todo)" :disabled="!isToday">
<span class="check-box" :class="{ checked: todo.is_completed }">
<span v-if="todo.is_completed" class="check-mark">&#10003;</span>
</span>
</button>
<div class="todo-content">
<span class="todo-title">{{ todo.title }}</span>
<span v-if="todo.source_detail" class="todo-source">{{ todo.source_detail }}</span>
</div>
<button v-if="isToday" class="del-btn" @click="deleteTodo(todo.id)">
<span>&#215;</span>
</button>
</div>
<!-- 空状态 -->
<div v-if="!loading && todos.length === 0" class="empty-state">
<span class="empty-icon">[ ]</span>
<span class="empty-text">{{ isToday ? '今日待办为空,点击上方新增' : '该日无待办记录' }}</span>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<span class="loading-text">LOADING...</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.todo-view {
display: flex;
flex-direction: column;
height: 100%;
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;
justify-content: space-between;
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 {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
.title-bracket { color: var(--accent-cyan); opacity: 0.6; }
.ai-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(249,168,37,0.08);
border: 1px solid rgba(249,168,37,0.3);
border-radius: var(--radius-sm);
color: var(--accent-amber);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
}
.ai-btn:hover { background: rgba(249,168,37,0.15); border-color: var(--accent-amber); box-shadow: 0 0 12px rgba(249,168,37,0.2); }
.ai-btn.loading { opacity: 0.7; cursor: not-allowed; }
.ai-spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 日期导航 */
.date-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-dim);
}
.date-btn {
padding: 5px 14px;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
gap: 6px;
}
.date-btn:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
.date-btn.active { border-color: var(--accent-cyan); color: var(--accent-cyan); background: rgba(0,245,212,0.08); }
.date-btn.primary { font-weight: 600; }
/* 内容区 */
.todo-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.add-form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.add-input {
flex: 1;
padding: 10px 16px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
transition: all var(--transition-fast);
}
.add-input:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px rgba(0,245,212,0.1);
}
.add-input::placeholder { color: var(--text-dim); }
.add-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-md);
color: var(--accent-cyan);
cursor: pointer;
transition: all var(--transition-fast);
}
.add-btn:hover { background: rgba(0,245,212,0.15); box-shadow: var(--glow-cyan); }
/* 待办列表 */
.todo-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.todo-item:hover { border-color: var(--border-mid); }
.todo-item.ai-source { border-left: 2px solid var(--accent-amber); }
.todo-item.completed { opacity: 0.5; }
.todo-item.completed .todo-title { text-decoration: line-through; color: var(--text-dim); }
.check-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
}
.check-btn:disabled { cursor: default; }
.check-box {
width: 18px;
height: 18px;
border: 1px solid var(--border-mid);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.check-box.checked { background: var(--accent-cyan); border-color: var(--accent-cyan); }
.check-mark { color: var(--bg-void); font-size: 12px; font-weight: bold; }
.todo-content { flex: 1; min-width: 0; }
.todo-title { display: block; font-size: 13px; color: var(--text-primary); font-family: var(--font-mono); }
.todo-source { display: block; font-size: 10px; color: var(--text-dim); margin-top: 3px; font-family: var(--font-mono); }
.del-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 18px;
border-radius: 4px;
transition: all var(--transition-fast);
}
.del-btn:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
/* 空/加载状态 */
.empty-state, .loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.empty-icon { font-family: var(--font-mono); font-size: 32px; color: var(--text-dim); opacity: 0.3; }
.empty-text { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); letter-spacing: 0.1em; }
.loading-text { font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); letter-spacing: 0.2em; animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
</style>