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