420 lines
11 KiB
Vue
420 lines
11 KiB
Vue
|
|
<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">✓</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>×</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>
|