Files
ToDoList/WebUI/src/components/QuadrantTaskCard.vue
祀梦 2979197b1c release: Elysia ToDo v1.0.0
鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆

Made-with: Cursor
2026-03-14 22:21:26 +08:00

377 lines
8.0 KiB
Vue

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTaskStore } from '@/stores/useTaskStore'
import { useUIStore } from '@/stores/useUIStore'
import type { TaskResponse } from '@/api/tasks'
import { ElMessageBox, ElMessage } from 'element-plus'
const props = defineProps<{
task: TaskResponse
}>()
const taskStore = useTaskStore()
const uiStore = useUIStore()
const isToggling = ref(false)
const isDeleting = ref(false)
const isAnimating = ref(false)
const showStars = ref(false)
const starPositions = [
{ x: -10, y: -10, delay: 0 },
{ x: 50, y: -15, delay: 50 },
{ x: 100, y: -10, delay: 100 },
{ x: -15, y: 30, delay: 75 },
{ x: 105, y: 35, delay: 125 }
]
const formattedDueDate = computed(() => {
if (!props.task.due_date) return null
const date = new Date(props.task.due_date)
const now = new Date()
const diff = date.getTime() - now.getTime()
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
if (days < 0) return { text: '已过期', class: 'overdue' }
if (days === 0) return { text: '今天截止', class: 'today' }
if (days === 1) return { text: '明天截止', class: 'tomorrow' }
return { text: `${date.getMonth() + 1}/${date.getDate()}`, class: '' }
})
const categoryColor = computed(() => props.task.category?.color)
const categoryName = computed(() => props.task.category?.name)
const tags = computed(() => props.task.tags ?? [])
async function handleToggle() {
if (isToggling.value) return
if (!props.task.is_completed) {
isAnimating.value = true
showStars.value = true
await new Promise(r => setTimeout(r, 500))
isAnimating.value = false
setTimeout(() => {
showStars.value = false
}, 600)
}
isToggling.value = true
try {
const result = await taskStore.toggleTask(props.task.id)
if (result && result.is_completed) {
ElMessage.success({
message: '太棒了!任务完成啦~',
duration: 2000
})
}
} finally {
isToggling.value = false
}
}
function handleEdit() {
uiStore.openTaskDialog(props.task)
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
'确定要删除这个任务吗?删除后可就找不回来了呢~',
'确认删除',
{
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
}
)
isDeleting.value = true
const success = await taskStore.deleteTask(props.task.id)
if (success) {
ElMessage.success('任务已删除')
}
} catch {
// 用户取消
} finally {
isDeleting.value = false
}
}
</script>
<template>
<div
class="quadrant-task-card"
:class="{
completed: task.is_completed,
'task-complete': isAnimating
}"
>
<div
v-if="showStars"
v-for="star in starPositions"
:key="`star-${star.delay}-${star.x}`"
class="star-burst"
:style="{
left: star.x + '%',
top: star.y + '%',
animationDelay: star.delay + 'ms'
}"
/>
<div class="card-content">
<el-checkbox
:model-value="task.is_completed"
:loading="isToggling"
size="small"
class="task-checkbox"
@change="handleToggle"
/>
<div class="task-info">
<div class="task-title-row">
<h4
class="task-title"
:class="{ 'line-through': task.is_completed }"
>
{{ task.title }}
</h4>
<span
v-if="formattedDueDate"
class="task-due"
:class="formattedDueDate.class"
>
<el-icon><Calendar /></el-icon>
{{ formattedDueDate.text }}
</span>
<el-button
text
size="small"
class="action-btn edit-btn"
@click="handleEdit"
>
<el-icon><Edit /></el-icon>
</el-button>
</div>
<div class="task-bottom-row">
<div v-if="categoryName || tags.length > 0" class="task-tags">
<span
v-if="categoryName"
class="meta-chip category-chip"
:style="{ '--chip-color': categoryColor }"
>
{{ categoryName }}
</span>
<span
v-for="tag in tags"
:key="tag.id"
class="meta-chip tag-chip"
>
#{{ tag.name }}
</span>
</div>
<el-button
text
size="small"
class="action-btn delete-btn"
:loading="isDeleting"
@click="handleDelete"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.quadrant-task-card {
position: relative;
padding: 10px 12px;
background: white;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
display: flex;
align-items: flex-start;
gap: 16px;
transition: all var(--transition-normal);
border-left: 3px solid var(--quadrant-color, var(--priority-q4));
overflow: hidden;
&:hover {
box-shadow: var(--shadow-md);
}
&.completed {
opacity: 0.6;
background: var(--background-dark);
.task-title {
text-decoration: line-through;
color: var(--text-secondary);
}
}
}
.task-checkbox {
flex-shrink: 0;
margin-right: 8px;
:deep(.el-checkbox__inner) {
border-radius: 50%;
border-color: var(--primary);
width: 18px;
height: 18px;
&::after {
height: 8px;
width: 4px;
}
}
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background-color: var(--success);
border-color: var(--success);
}
:deep(.el-checkbox__label) {
display: none;
}
}
.card-content {
display: flex;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.task-info {
flex: 1;
min-width: 0;
}
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.task-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
&.line-through {
text-decoration: line-through;
}
}
.task-due {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--text-secondary);
padding: 2px 6px;
background: var(--background-dark);
border-radius: 4px;
flex-shrink: 0;
&.overdue {
background: rgba(255, 107, 107, 0.15);
color: var(--danger);
}
&.today {
background: rgba(255, 179, 71, 0.15);
color: var(--warning);
}
&.tomorrow {
background: rgba(152, 216, 200, 0.15);
color: var(--success);
}
}
.task-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
}
.task-bottom-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.meta-chip {
display: inline-flex;
align-items: center;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
line-height: 1.5;
}
.category-chip {
background: color-mix(in srgb, var(--chip-color, #ccc) 15%, transparent);
color: var(--chip-color, var(--text-secondary));
font-weight: 500;
}
.tag-chip {
background: var(--background-dark);
color: var(--text-secondary);
}
.action-btn {
color: var(--text-secondary);
padding: 4px;
min-height: auto;
flex-shrink: 0;
&:hover {
color: var(--primary);
}
&.delete-btn:hover {
color: var(--danger);
}
}
.star-burst {
position: absolute;
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23FFB7C5'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E") no-repeat center;
animation: starBurst 0.6s ease forwards;
}
@keyframes starBurst {
0% {
opacity: 1;
transform: scale(0) translate(0, 0);
}
50% {
opacity: 1;
transform: scale(1) translate(10px, -10px);
}
100% {
opacity: 0;
transform: scale(0.5) translate(20px, -20px);
}
}
</style>