Files
JARVIS/frontend/src/pages/todo/index.vue

420 lines
11 KiB
Vue
Raw Normal View History

2026-03-21 10:13:35 +08:00
<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>