Files
todo-vibe-coding/web/src/views/TodoView.vue
2026-02-13 23:23:36 +08:00

215 lines
5.9 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Task } from '../types'
import * as api from '../services/api'
import PaginationControl from '../components/PaginationControl.vue'
import TodoEditorModal from '../components/TodoEditorModal.vue'
import { t } from '../i18n'
import { useToast } from '../composables/useToast'
const { success, error: showError } = useToast()
const tasks = ref<Task[]>([])
const loading = ref(false)
const modalOpen = ref(false)
const editingTask = ref<Task | null>(null)
const page = ref(1)
const pageSize = ref(6)
const filter = ref<'all' | 'todo' | 'done'>('all')
const filteredTasks = computed(() => {
if (filter.value === 'all') return tasks.value
return tasks.value.filter((t) => t.status === filter.value)
})
const total = computed(() => filteredTasks.value.length)
const pages = computed(() => Math.max(Math.ceil(total.value / pageSize.value), 1))
const pagedTasks = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredTasks.value.slice(start, start + pageSize.value)
})
async function loadTasks() {
loading.value = true
try {
const list = await api.listTasks()
tasks.value = list.sort((a, b) => Number(new Date(b.updated_at)) - Number(new Date(a.updated_at)))
if (page.value > pages.value) page.value = pages.value
} catch {
showError(t('load_tasks_failed'))
} finally {
loading.value = false
}
}
function openCreate() {
editingTask.value = null
modalOpen.value = true
}
function openEdit(task: Task) {
editingTask.value = task
modalOpen.value = true
}
async function saveTask(payload: Partial<Task>) {
try {
if (editingTask.value?.id) {
await api.updateTask(editingTask.value.id, payload)
success(t('task_updated_success') || 'Task updated')
} else {
await api.createTask({ ...payload, status: 'todo' })
success(t('task_created_success') || 'Task created')
}
modalOpen.value = false
editingTask.value = null
await loadTasks()
} catch {
showError(t('save_task_failed'))
}
}
async function toggle(task: Task) {
const originalStatus = task.status
const newStatus = task.status === 'done' ? 'todo' : 'done'
// Optimistic update
task.status = newStatus
try {
await api.updateTask(task.id, { status: newStatus })
// Background refresh to ensure consistency
// await loadTasks() // Optional: might cause jumping if list sort changes
} catch {
// Revert on failure
task.status = originalStatus
showError(t('update_task_failed'))
}
}
async function remove(id: number) {
if (!confirm(t('confirm_delete') || 'Are you sure?')) return
const originalTasks = [...tasks.value]
tasks.value = tasks.value.filter(t => t.id !== id)
try {
await api.deleteTask(id)
success(t('task_deleted_success') || 'Task deleted')
} catch {
tasks.value = originalTasks
showError(t('delete_task_failed'))
}
}
function nextPage() {
if (page.value < pages.value) page.value += 1
}
function prevPage() {
if (page.value > 1) page.value -= 1
}
function formatDue(value: string) {
return value ? new Date(value).toLocaleString() : t('due_empty')
}
function setFilter(f: 'all' | 'todo' | 'done') {
filter.value = f
page.value = 1
}
onMounted(loadTasks)
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<h2>{{ t('todo_title') }}</h2>
<p>{{ t('todo_subtitle') }}</p>
</div>
<div class="page-actions">
<div class="filter-group">
<button
class="btn sm"
:class="{ primary: filter === 'all' }"
@click="setFilter('all')"
>All</button>
<button
class="btn sm"
:class="{ primary: filter === 'todo' }"
@click="setFilter('todo')"
>Active</button>
<button
class="btn sm"
:class="{ primary: filter === 'done' }"
@click="setFilter('done')"
>Done</button>
</div>
<button class="btn" @click="loadTasks">{{ t('refresh') }}</button>
<button class="btn primary" @click="openCreate">{{ t('new_todo') }}</button>
</div>
</header>
<div class="cards" v-if="!loading">
<div v-if="pagedTasks.length === 0" class="empty-state">
<p>No tasks found.</p>
</div>
<article v-for="task in pagedTasks" :key="task.id" class="task-row" :class="{ done: task.status === 'done' }">
<div class="task-row-main">
<input
class="task-check"
type="checkbox"
:checked="task.status === 'done'"
@change="toggle(task)"
:aria-label="task.title"
/>
<div class="task-row-content">
<h3>{{ task.title }}</h3>
<p>{{ task.description || t('no_description') }}</p>
</div>
</div>
<div class="task-row-meta">
<div class="task-meta">
<small>{{ t('priority') }} P{{ task.priority || 2 }}</small>
<small>{{ t('due_at') }} {{ formatDue(task.due_at) }}</small>
</div>
</div>
<div class="task-row-actions">
<button class="btn" @click="openEdit(task)">{{ t('edit') }}</button>
<button class="btn danger" @click="remove(task.id)">{{ t('delete') }}</button>
</div>
</article>
</div>
<p v-else>{{ t('loading_tasks') }}</p>
<PaginationControl :page="page" :page-size="pageSize" :total="total" @next="nextPage" @prev="prevPage" />
<TodoEditorModal v-model="modalOpen" :editing-task="editingTask" @submit="saveTask" />
</section>
</template>
<style scoped>
.filter-group {
display: flex;
gap: 0.5rem;
margin-right: 1rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
grid-column: 1 / -1;
}
.btn.sm {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
</style>