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>
479 lines
13 KiB
Vue
479 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { taskApi, type Task, type TaskStatus, type TaskPriority } from '@/api/task'
|
|
import { Plus, CheckCircle, Circle, Clock, Trash2, Zap } from 'lucide-vue-next'
|
|
|
|
const tasks = ref<Task[]>([])
|
|
const showCreateForm = ref(false)
|
|
const newTaskTitle = ref('')
|
|
const newTaskPriority = ref<TaskPriority>('medium')
|
|
|
|
const todoTasks = computed(() => tasks.value.filter((t) => t.status === 'todo'))
|
|
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
|
|
const doneTasks = computed(() => tasks.value.filter((t) => t.status === 'done'))
|
|
|
|
const priorityConfig: Record<TaskPriority, { color: string; label: string; glow: string }> = {
|
|
low: { color: '#4b5563', label: 'LOW', glow: 'rgba(75,85,99,0.3)' },
|
|
medium: { color: '#60a5fa', label: 'MED', glow: 'rgba(96,165,250,0.3)' },
|
|
high: { color: '#fbbf24', label: 'HIGH', glow: 'rgba(251,191,36,0.3)' },
|
|
urgent: { color: '#f87171', label: 'CRIT', glow: 'rgba(248,113,113,0.3)' },
|
|
}
|
|
|
|
async function loadTasks() {
|
|
try {
|
|
const response = await taskApi.list()
|
|
tasks.value = response.data
|
|
} catch (e) { console.error('加载任务失败:', e) }
|
|
}
|
|
|
|
async function createTask() {
|
|
if (!newTaskTitle.value.trim()) return
|
|
try {
|
|
const response = await taskApi.create({ title: newTaskTitle.value.trim(), priority: newTaskPriority.value })
|
|
tasks.value.unshift(response.data)
|
|
newTaskTitle.value = ''
|
|
showCreateForm.value = false
|
|
} catch (e) { console.error('创建任务失败:', e) }
|
|
}
|
|
|
|
async function updateStatus(task: Task, status: TaskStatus) {
|
|
try {
|
|
const response = await taskApi.update(task.id, { status })
|
|
const index = tasks.value.findIndex((t) => t.id === task.id)
|
|
if (index !== -1) tasks.value[index] = response.data
|
|
} catch (e) { console.error('更新状态失败:', e) }
|
|
}
|
|
|
|
async function deleteTask(id: string) {
|
|
try {
|
|
await taskApi.delete(id)
|
|
tasks.value = tasks.value.filter((t) => t.id !== id)
|
|
} catch (e) { console.error('删除失败:', e) }
|
|
}
|
|
|
|
onMounted(() => { loadTasks() })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="kanban-view">
|
|
<!-- Header -->
|
|
<div class="page-header">
|
|
<div class="header-left">
|
|
<div class="header-icon"><Zap :size="20" /></div>
|
|
<div class="header-text">
|
|
<h1>TASK BOARD</h1>
|
|
<span class="header-sub">{{ tasks.length }} tasks · {{ doneTasks.length }} completed</span>
|
|
</div>
|
|
</div>
|
|
<button class="add-btn" @click="showCreateForm = true">
|
|
<Plus :size="14" />
|
|
NEW TASK
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Create form -->
|
|
<div v-if="showCreateForm" class="create-panel">
|
|
<div class="create-inner">
|
|
<input
|
|
v-model="newTaskTitle"
|
|
placeholder="Describe the task..."
|
|
@keyup.enter="createTask"
|
|
autofocus
|
|
/>
|
|
<select v-model="newTaskPriority" class="priority-select">
|
|
<option value="low">LOW</option>
|
|
<option value="medium">MEDIUM</option>
|
|
<option value="high">HIGH</option>
|
|
<option value="urgent">CRITICAL</option>
|
|
</select>
|
|
<button class="confirm-btn" @click="createTask">CREATE</button>
|
|
<button class="cancel-btn" @click="showCreateForm = false">CANCEL</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Board -->
|
|
<div class="kanban-board">
|
|
<!-- TODO -->
|
|
<div class="kanban-col">
|
|
<div class="col-header">
|
|
<div class="col-title">
|
|
<Circle :size="14" />
|
|
<span>PENDING</span>
|
|
<div class="col-count">{{ todoTasks.length }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-line" style="--col-color: #60a5fa"></div>
|
|
<div class="col-cards">
|
|
<div
|
|
v-for="task in todoTasks"
|
|
:key="task.id"
|
|
class="task-card"
|
|
@click="updateStatus(task, 'in_progress')"
|
|
>
|
|
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow }"></div>
|
|
<div class="task-body">
|
|
<div class="task-meta">
|
|
<span class="task-priority-tag" :style="{ color: priorityConfig[task.priority].color }">
|
|
{{ priorityConfig[task.priority].label }}
|
|
</span>
|
|
</div>
|
|
<div class="task-title">{{ task.title }}</div>
|
|
</div>
|
|
<button class="task-delete" @click.stop="deleteTask(task.id)">
|
|
<Trash2 :size="12" />
|
|
</button>
|
|
</div>
|
|
<div v-if="todoTasks.length === 0" class="col-empty">No pending tasks</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- IN PROGRESS -->
|
|
<div class="kanban-col active-col">
|
|
<div class="col-header">
|
|
<div class="col-title">
|
|
<Clock :size="14" />
|
|
<span>IN PROGRESS</span>
|
|
<div class="col-count active">{{ inProgressTasks.length }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-line" style="--col-color: #fbbf24"></div>
|
|
<div class="col-cards">
|
|
<div
|
|
v-for="task in inProgressTasks"
|
|
:key="task.id"
|
|
class="task-card"
|
|
@click="updateStatus(task, 'done')"
|
|
>
|
|
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow }"></div>
|
|
<div class="task-body">
|
|
<div class="task-meta">
|
|
<span class="task-priority-tag" :style="{ color: priorityConfig[task.priority].color }">
|
|
{{ priorityConfig[task.priority].label }}
|
|
</span>
|
|
<span class="active-dot"></span>
|
|
</div>
|
|
<div class="task-title">{{ task.title }}</div>
|
|
</div>
|
|
<button class="task-delete" @click.stop="deleteTask(task.id)">
|
|
<Trash2 :size="12" />
|
|
</button>
|
|
</div>
|
|
<div v-if="inProgressTasks.length === 0" class="col-empty">No active tasks</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- DONE -->
|
|
<div class="kanban-col">
|
|
<div class="col-header">
|
|
<div class="col-title">
|
|
<CheckCircle :size="14" />
|
|
<span>COMPLETED</span>
|
|
<div class="col-count">{{ doneTasks.length }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-line" style="--col-color: #34d399"></div>
|
|
<div class="col-cards">
|
|
<div
|
|
v-for="task in doneTasks"
|
|
:key="task.id"
|
|
class="task-card done"
|
|
@click="updateStatus(task, 'todo')"
|
|
>
|
|
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow, opacity: 0.4 }"></div>
|
|
<div class="task-body">
|
|
<div class="task-meta">
|
|
<span class="task-priority-tag" style="color: var(--text-dim)">
|
|
{{ priorityConfig[task.priority].label }}
|
|
</span>
|
|
</div>
|
|
<div class="task-title done-title">{{ task.title }}</div>
|
|
</div>
|
|
<button class="task-delete" @click.stop="deleteTask(task.id)">
|
|
<Trash2 :size="12" />
|
|
</button>
|
|
</div>
|
|
<div v-if="doneTasks.length === 0" class="col-empty">No completed tasks</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.kanban-view {
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.header-left { display: flex; align-items: center; gap: 14px; }
|
|
|
|
.header-icon { color: var(--accent-amber); filter: drop-shadow(0 0 8px var(--accent-amber)); }
|
|
|
|
h1 {
|
|
font-family: var(--font-display);
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.15em;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
}
|
|
|
|
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
|
|
|
|
.add-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 16px;
|
|
background: var(--accent-amber-dim);
|
|
border: 1px solid rgba(249, 168, 37, 0.25);
|
|
border-radius: var(--radius-md);
|
|
color: var(--accent-amber);
|
|
font-family: var(--font-display);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
transition: all var(--transition-fast);
|
|
}
|
|
|
|
.add-btn:hover {
|
|
background: rgba(249, 168, 37, 0.2);
|
|
box-shadow: var(--glow-amber);
|
|
}
|
|
|
|
/* Create panel */
|
|
.create-panel {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-mid);
|
|
border-radius: var(--radius-lg);
|
|
padding: 16px;
|
|
animation: fade-in-up 0.2s ease;
|
|
}
|
|
|
|
.create-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.create-inner input {
|
|
flex: 1;
|
|
padding: 10px 14px;
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--border-mid);
|
|
border-radius: var(--radius-md);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.create-inner input:focus {
|
|
border-color: var(--accent-amber);
|
|
box-shadow: var(--glow-amber);
|
|
}
|
|
|
|
.priority-select {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.1em;
|
|
padding: 8px 12px;
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--border-dim);
|
|
border-radius: var(--radius-md);
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.confirm-btn {
|
|
padding: 10px 20px;
|
|
background: var(--accent-amber-dim);
|
|
border: 1px solid rgba(249, 168, 37, 0.3);
|
|
border-radius: var(--radius-md);
|
|
color: var(--accent-amber);
|
|
font-family: var(--font-display);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
transition: all var(--transition-fast);
|
|
}
|
|
|
|
.confirm-btn:hover { background: rgba(249, 168, 37, 0.2); box-shadow: var(--glow-amber); }
|
|
|
|
.cancel-btn {
|
|
padding: 10px 16px;
|
|
background: transparent;
|
|
border: 1px solid var(--border-dim);
|
|
border-radius: var(--radius-md);
|
|
color: var(--text-dim);
|
|
font-family: var(--font-display);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
transition: all var(--transition-fast);
|
|
}
|
|
|
|
.cancel-btn:hover { border-color: var(--accent-red); color: var(--accent-red); }
|
|
|
|
/* Board */
|
|
.kanban-board {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 16px;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.kanban-col {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-dim);
|
|
border-radius: var(--radius-lg);
|
|
padding: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
min-height: 400px;
|
|
}
|
|
|
|
.kanban-col.active-col {
|
|
border-color: rgba(251, 191, 36, 0.2);
|
|
background: rgba(251, 191, 36, 0.02);
|
|
}
|
|
|
|
.col-header { }
|
|
|
|
.col-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-family: var(--font-display);
|
|
font-size: 10px;
|
|
letter-spacing: 0.15em;
|
|
color: var(--text-dim);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.col-count {
|
|
margin-left: auto;
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--border-dim);
|
|
border-radius: 10px;
|
|
padding: 1px 8px;
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.col-count.active {
|
|
background: var(--accent-amber-dim);
|
|
border-color: rgba(249, 168, 37, 0.3);
|
|
color: var(--accent-amber);
|
|
}
|
|
|
|
.col-line {
|
|
height: 1px;
|
|
background: linear-gradient(90deg, var(--col-color, var(--accent-cyan)), transparent);
|
|
margin-bottom: 4px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.col-cards {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.col-empty {
|
|
text-align: center;
|
|
padding: 32px 16px;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
.task-card {
|
|
background: var(--bg-panel);
|
|
border: 1px solid var(--border-dim);
|
|
border-radius: var(--radius-md);
|
|
padding: 10px 12px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: 10px;
|
|
transition: all var(--transition-fast);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.task-card:hover {
|
|
border-color: var(--border-mid);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.task-card.done { opacity: 0.55; }
|
|
|
|
.task-priority-bar {
|
|
width: 3px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.task-body { flex: 1; min-width: 0; }
|
|
|
|
.task-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.task-priority-tag {
|
|
font-family: var(--font-display);
|
|
font-size: 8px;
|
|
letter-spacing: 0.1em;
|
|
}
|
|
|
|
.active-dot {
|
|
width: 5px;
|
|
height: 5px;
|
|
border-radius: 50%;
|
|
background: var(--accent-amber);
|
|
box-shadow: 0 0 6px var(--accent-amber);
|
|
animation: pulse-glow 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.task-title {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.done-title {
|
|
text-decoration: line-through;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.task-delete {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
border-radius: 3px;
|
|
opacity: 0;
|
|
transition: all var(--transition-fast);
|
|
flex-shrink: 0;
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.task-card:hover .task-delete { opacity: 1; }
|
|
.task-delete:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
|
|
</style>
|