Version: 0.9.38.dev.260423

后端:
1. 四象限任务新增修改与删除接口——部分更新语义 + 硬删除 + 幂等信息码
- 新增 PUT/task/update:指针字段部分更新(title / priority_group / deadline_at / urgency_threshold_at),优先级 1~4 校验,空更新检测
- 新增 DELETE /task/delete:硬删除,重复删除返回 10003 幂等信息码
- 新增错误码 TaskUpdateNoFields (40063) 与 TaskAlreadyDeleted (10003)

前端:
1. 四象限卡片对接修改与删除
- 任务项重构为三区布局:勾选、内容点击编辑、悬浮删除按钮 - 创建弹窗复用为编辑模式,新增 urgency_threshold_at 字段
- 删除走二次确认弹窗,空状态增加 SVG 插画
2. 今日时间轴微调——色调简化为取模轮换,午休/晚餐改称午间/晚休
This commit is contained in:
Losita
2026-04-23 19:46:33 +08:00
parent 7b37db64eb
commit 53e2602df4
15 changed files with 1388 additions and 490 deletions

View File

@@ -1,6 +1,6 @@
import http from '@/api/http'
import type { ApiResponse } from '@/types/api'
import type { TaskCreatePayload, TaskCreateResult, TaskItem, TaskMutationResult } from '@/types/dashboard'
import type { TaskUpdatePayload, TaskCreatePayload, TaskCreateResult, TaskItem, TaskMutationResult } from '@/types/dashboard'
import { createIdempotencyKey } from '@/utils/idempotency'
import { extractErrorMessage } from '@/utils/http'
@@ -59,3 +59,37 @@ export async function undoCompleteTask(taskId: number) {
throw new Error(extractErrorMessage(error, '恢复任务失败,请稍后重试'))
}
}
export async function updateTask(payload: TaskUpdatePayload) {
try {
const response = await http.put<ApiResponse<TaskItem>>(
'/task/update',
payload,
{
headers: {
'X-Idempotency-Key': createIdempotencyKey('task-update'),
},
}
)
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '修改任务失败,请稍后重试'))
}
}
export async function deleteTask(taskId: number) {
try {
const response = await http.delete<ApiResponse<{ task_id: number }>>(
'/task/delete',
{
data: { task_id: taskId },
headers: {
'X-Idempotency-Key': createIdempotencyKey('task-delete'),
},
}
)
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '删除任务失败,请稍后重试'))
}
}

View File

@@ -16,9 +16,10 @@ const props = defineProps<{
const emit = defineEmits<{
toggle: [task: TaskItem]
edit: [task: TaskItem]
delete: [task: TaskItem]
}>()
// 不再硬截断,全部展示;超出的部分通过 quadrant-list 的 max-height + overflow-y 滚动查看。
const visibleTasks = computed(() => props.tasks)
</script>
@@ -38,29 +39,51 @@ const visibleTasks = computed(() => props.tasks)
</div>
<div v-else-if="visibleTasks.length === 0" key="empty" class="quadrant-card__empty">
{{ emptyText }}
<div class="empty-placeholder">
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 8v1.5M12 12.5V16"></path>
</svg>
<p>{{ emptyText }}</p>
</div>
</div>
<TransitionGroup v-else tag="div" name="list-stagger" class="quadrant-list" key="list">
<button
<div
v-for="task in visibleTasks"
:key="task.id"
type="button"
class="quadrant-item"
:class="{ 'quadrant-item--completed': task.is_completed }"
@click="emit('toggle', task)"
>
<span class="quadrant-item__check">
{{ task.is_completed ? '✓' : '' }}
</span>
<span class="quadrant-item__content">
<strong>{{ task.title }}</strong>
<small>{{ formatDeadline(task.deadline) }}</small>
</span>
<span class="quadrant-item__status">
{{ task.is_completed ? '已完成' : '待处理' }}
</span>
</button>
<!-- 区域1: 独立勾选框 (阻止冒泡) -->
<button
type="button"
class="quadrant-item__check-btn"
@click.stop="emit('toggle', task)"
>
<span class="quadrant-item__check">
<svg v-if="task.is_completed" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="4">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</span>
</button>
<!-- 区域2: 文本内容 (点击触发编辑) -->
<div class="quadrant-item__body" @click="emit('edit', task)">
<strong class="quadrant-item__title">{{ task.title }}</strong>
<small class="quadrant-item__time">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
{{ formatDeadline(task.deadline) }}
</small>
</div>
<!-- 区域3: 悬浮操作区 (平滑滑入) -->
<div class="quadrant-item__actions">
<button class="action-btn delete" @click.stop="emit('delete', task)" title="删除任务">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
</button>
</div>
</div>
</TransitionGroup>
</transition>
</section>
@@ -117,7 +140,7 @@ const visibleTasks = computed(() => props.tasks)
.quadrant-card__count {
min-width: 64px;
padding: 8px 12px;
border-radius: 999px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.78);
font-size: 13px;
font-weight: 700;
@@ -128,57 +151,78 @@ const visibleTasks = computed(() => props.tasks)
.quadrant-list {
display: grid;
gap: 12px;
/* 卡片 header 约 70px列表区域最多约 320px约 4 条可见),超出部分滚动 */
max-height: 320px;
overflow-y: auto;
/* 滚动条样式轨道透明滑块圆角淡色hover 加深 */
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.32) transparent;
}
.quadrant-list::-webkit-scrollbar {
width: 5px;
.quadrant-list::-webkit-scrollbar { width: 5px; }
.quadrant-list::-webkit-scrollbar-track { background: transparent; }
.quadrant-list::-webkit-scrollbar-thumb { border-radius: 999px; background: rgba(148, 163, 184, 0.32); }
.quadrant-card__empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 30px 10px;
}
.quadrant-list::-webkit-scrollbar-track {
background: transparent;
.empty-placeholder {
width: 100%;
height: 100%;
min-height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #94a3b8;
transition: all 0.4s ease;
}
.quadrant-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(148, 163, 184, 0.32);
.empty-placeholder:hover {
transform: translateY(-2px);
}
.quadrant-list::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.52);
.empty-icon {
width: 40px;
height: 40px;
margin-bottom: 12px;
opacity: 0.15; /* 极低不透明度,实现融合感 */
color: #64748b;
stroke-width: 1.2;
}
.quadrant-item,
.quadrant-card__skeleton-item {
border-radius: 18px;
min-height: 72px;
.empty-placeholder p {
margin: 0;
font-size: 13px;
font-weight: 600;
line-height: 1.6;
max-width: 180px;
color: #64748b;
opacity: 0.35; /* 文字也保持半透明融合 */
letter-spacing: 0.02em;
}
.quadrant-item {
width: 100%;
position: relative;
border-radius: 18px;
min-height: 72px;
border: 1px solid rgba(17, 24, 39, 0.06);
background: rgba(255, 255, 255, 0.92);
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 14px;
background: rgba(255, 255, 255, 0.95);
display: flex;
align-items: center;
padding: 14px 16px;
text-align: left;
cursor: pointer;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.quadrant-item:hover {
transform: translateY(-2px);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.08);
box-shadow: 0 12px 24px -8px rgba(15, 23, 42, 0.12);
border-color: rgba(37, 99, 235, 0.16);
}
@@ -186,113 +230,129 @@ const visibleTasks = computed(() => props.tasks)
opacity: 0.82;
}
/* 区域1: 勾选按钮 */
.quadrant-item__check-btn {
border: none;
background: transparent;
padding: 0;
margin-right: 14px;
cursor: pointer;
display: flex;
align-items: center;
}
.quadrant-item__check {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: #f8fafc;
border-radius: 50%;
border: 2px solid #e2e8f0;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
color: #2c8d57;
font-weight: 800;
color: #fff;
transition: all 0.2s;
}
.quadrant-item__content {
.quadrant-item:hover .quadrant-item__check {
border-color: #3b82f6;
}
.quadrant-item--completed .quadrant-item__check {
background: #10b981;
border-color: #10b981;
}
/* 区域2: 主体文字 */
.quadrant-item__body {
flex: 1;
min-width: 0;
cursor: pointer;
}
.quadrant-item__content strong,
.quadrant-item__content small {
.quadrant-item__title {
display: block;
}
.quadrant-item__content strong {
font-size: 16px;
color: #122033;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
transition: all 0.3s;
}
.quadrant-item--completed .quadrant-item__content strong {
.quadrant-item--completed .quadrant-item__title {
color: #96a0af;
text-decoration: line-through;
}
.quadrant-item__content small {
margin-top: 6px;
.quadrant-item__time {
display: flex;
align-items: center;
gap: 4px;
color: #768396;
font-size: 12px;
}
.quadrant-item__status {
font-size: 13px;
color: #8090a5;
white-space: nowrap;
}
.quadrant-card__empty {
min-height: 160px;
/* 区域3: 悬浮动作条 */
.quadrant-item__actions {
position: absolute;
right: -56px;
top: 0;
bottom: 0;
width: 56px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
color: #8b97a7;
font-size: 16px;
border-left: 1px solid rgba(0, 0, 0, 0.03);
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
opacity: 0;
}
.quadrant-card__skeleton {
display: grid;
gap: 12px;
.quadrant-item:hover .quadrant-item__actions {
right: 0;
opacity: 1;
}
.action-btn {
width: 36px;
height: 36px;
border: none;
border-radius: 12px;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-btn.delete { color: #f43f5e; }
.action-btn.delete:hover { background: #fee2e2; transform: scale(1.1); }
/* --- 骨架屏 --- */
.quadrant-card__skeleton-item {
border-radius: 18px;
min-height: 72px;
background: linear-gradient(90deg, rgba(230, 236, 244, 0.8), rgba(245, 248, 252, 1), rgba(230, 236, 244, 0.8));
background-size: 200% 100%;
animation: quadrant-shimmer 1.4s linear infinite;
}
@keyframes quadrant-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.fade-switch-enter-active,
.fade-switch-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
/* --- 动画 --- */
.fade-switch-enter-active, .fade-switch-leave-active { transition: opacity 0.25s ease, transform 0.25s ease; }
.fade-switch-enter-from { opacity: 0; transform: translateY(4px); }
.fade-switch-leave-to { opacity: 0; transform: translateY(-4px); }
.fade-switch-enter-from {
opacity: 0;
transform: translateY(4px);
}
.fade-switch-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.list-stagger-enter-active,
.list-stagger-leave-active {
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.list-stagger-enter-from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
.list-stagger-leave-to {
opacity: 0;
transform: translateX(12px) scale(0.98);
}
.list-stagger-leave-active {
position: absolute;
}
.list-stagger-enter-active, .list-stagger-leave-active { transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); }
.list-stagger-enter-from { opacity: 0; transform: translateY(12px) scale(0.98); }
.list-stagger-leave-to { opacity: 0; transform: translateX(12px) scale(0.98); }
.list-stagger-leave-active { position: absolute; }
</style>

View File

@@ -44,16 +44,13 @@ const props = defineProps<{
loading?: boolean
}>()
// 1. 时间轴始终固定为 8 个槽位,顺序不再受当天是否有课影响。
// 2. 课程槽位缺数据时显示“无课”,而不是直接消失,避免把后续块位挤乱。
// 3. 午休和晚餐是纯占位块,不展示时间文本,只负责占住用户指定的位置。
const slotBlueprint: TimelineSlot[] = [
{ key: 'slot-1', kind: 'event', title: '1-2节', startTime: '08:00', endTime: '09:40' },
{ key: 'slot-2', kind: 'event', title: '3-4节', startTime: '10:15', endTime: '11:55' },
{ key: 'slot-noon', kind: 'pause', title: '午' },
{ key: 'slot-noon', kind: 'pause', title: '午' },
{ key: 'slot-4', kind: 'event', title: '5-6节', startTime: '14:00', endTime: '15:40' },
{ key: 'slot-5', kind: 'event', title: '7-8节', startTime: '16:15', endTime: '17:55' },
{ key: 'slot-dinner', kind: 'pause', title: '晚' },
{ key: 'slot-dinner', kind: 'pause', title: '晚' },
{ key: 'slot-6', kind: 'event', title: '9-10节', startTime: '19:00', endTime: '20:40' },
{ key: 'slot-7', kind: 'event', title: '11-12节', startTime: '20:50', endTime: '22:30' },
]
@@ -70,25 +67,13 @@ const eventMap = computed(() => {
return map
})
// 统一色调系统
function resolveCardTone(event: TodayEvent | null) {
if (!event) {
return 'neutral'
}
if (event.type === 'course') {
return 'course'
}
const orderToneMap: Record<number, string> = {
1: 'sky',
2: 'violet',
4: 'mint',
5: 'emerald',
6: 'amber',
7: 'cyan',
}
return orderToneMap[event.order] ?? 'neutral'
if (!event) return 'empty'
if (event.type === 'course') return 'primary'
const tones = ['sky', 'violet', 'mint', 'amber', 'rose']
return tones[event.order % tones.length]
}
const renderSlots = computed<RenderSlot[]>(() =>
@@ -98,7 +83,7 @@ const renderSlots = computed<RenderSlot[]>(() =>
key: slot.key,
kind: 'pause',
title: slot.title,
hint: '为中段留出缓冲与恢复时间',
hint: 'Rest Time',
}
}
@@ -107,7 +92,7 @@ const renderSlots = computed<RenderSlot[]>(() =>
key: slot.key,
kind: 'event',
timeText: formatTimeRange(event?.start_time || slot.startTime, event?.end_time || slot.endTime),
title: event?.name || '无课',
title: event?.name || '今日无安排',
locationText: event?.location || '休息时间',
tone: resolveCardTone(event),
}
@@ -116,35 +101,39 @@ const renderSlots = computed<RenderSlot[]>(() =>
</script>
<template>
<section class="timeline-card glass-panel">
<header class="timeline-card__header">
<div>
<p class="timeline-card__eyebrow">今日总览</p>
<h2>今日日程一览</h2>
<section class="pastel-container">
<header class="pastel-header">
<div class="header-content">
<p class="header-label">DAILY TIMELINE</p>
<h2>今日日程</h2>
</div>
<span class="timeline-card__caption">按时间顺序展示课程与任务安排</span>
</header>
<transition name="fade-switch" mode="out-in">
<div v-if="loading" key="loading" class="timeline-skeleton">
<div v-for="slot in slotBlueprint" :key="slot.key" class="timeline-skeleton__item" />
<transition name="grid-pop" mode="out-in">
<div v-if="loading" key="loading" class="pastel-grid">
<div v-for="n in 8" :key="n" class="skeleton-pill" />
</div>
<div v-else key="content" class="timeline-grid">
<div v-else key="content" class="pastel-grid">
<template v-for="slot in renderSlots" :key="slot.key">
<article
v-if="slot.kind === 'event'"
class="timeline-event"
:class="`timeline-event--${slot.tone}`"
class="pastel-item"
:class="[`tone--${slot.tone}`]"
>
<span class="timeline-event__time">{{ slot.timeText }}</span>
<strong class="timeline-event__title">{{ slot.title }}</strong>
<span class="timeline-event__location">{{ slot.locationText }}</span>
<div class="item-time">{{ slot.timeText }}</div>
<strong class="item-title">{{ slot.title }}</strong>
<div class="item-footer">
<svg class="location-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<span class="location-text">{{ slot.locationText }}</span>
</div>
</article>
<article v-else class="timeline-placeholder timeline-placeholder--pause">
<strong class="timeline-placeholder__title">{{ slot.title }}</strong>
<span class="timeline-placeholder__hint">{{ slot.hint }}</span>
<article v-else class="pause-item">
<span class="pause-tag">{{ slot.title }}</span>
</article>
</template>
</div>
@@ -153,281 +142,150 @@ const renderSlots = computed<RenderSlot[]>(() =>
</template>
<style scoped>
.timeline-card {
min-width: 0;
border-radius: 28px;
padding: 22px 22px 20px;
border: 1px solid rgba(17, 24, 39, 0.08);
.pastel-container {
padding: 32px;
background: #ffffff;
border-radius: 32px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.02);
}
.timeline-card__header {
.pastel-header {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-end;
margin-bottom: 18px;
align-items: center;
margin-bottom: 28px;
}
.timeline-card__eyebrow {
margin: 0 0 6px;
font-size: 12px;
font-weight: 700;
color: #2a6fdf;
letter-spacing: 0.08em;
text-transform: uppercase;
.header-label {
font-size: 11px;
font-weight: 900;
color: #c4c9d5;
letter-spacing: 0.15em;
margin: 0 0 4px;
}
.timeline-card h2 {
.header-content h2 {
margin: 0;
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.03em;
font-size: 24px;
font-weight: 800;
color: #1e293b;
}
.timeline-card__caption {
color: var(--text-secondary);
font-size: 13px;
}
.timeline-grid {
min-width: 0;
.pastel-grid {
display: grid;
/* 1. 使用自适应列数,避免固定列数把左侧主区撑爆。 */
/* 2. 但槽位顺序固定,换行只影响视觉换行,不影响时间先后顺序。 */
/* 3. 这样无论是否缺课8 个槽位都会按既定顺序逐个渲染。 */
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 12px;
overflow: visible;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 14px;
}
.timeline-event,
.timeline-placeholder,
.timeline-skeleton__item {
min-width: 0;
min-height: 124px;
border-radius: 14px;
}
.timeline-event {
padding: 16px 14px;
/* 核心卡片样式 */
.pastel-item {
position: relative;
border-radius: 26px;
padding: 20px 18px;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid transparent;
position: relative;
overflow: hidden;
min-height: 140px;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: default;
}
.timeline-event::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
.pastel-item:hover {
transform: scale(1.03) translateY(-4px);
box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.timeline-event__time {
.item-time {
font-size: 12px;
font-weight: 700;
color: #64748b;
font-weight: 800;
opacity: 0.6;
margin-bottom: 8px;
}
.timeline-event__title {
margin-top: 12px;
.item-title {
font-size: 18px;
font-weight: 850;
line-height: 1.3;
margin-top: 4px;
flex: 1;
}
.item-footer {
margin-top: 16px;
display: flex;
align-items: center;
gap: 6px;
}
.location-svg {
width: 13px;
height: 13px;
opacity: 0.8;
}
.location-text {
font-size: 13px;
font-weight: 700;
opacity: 0.8;
}
/* 莫兰迪色相系统 */
.tone--primary { background: #eff6ff; color: #1e40af; }
.tone--sky { background: #f0f9ff; color: #0369a1; }
.tone--violet { background: #f5f3ff; color: #5b21b6; }
.tone--mint { background: #ecfdf5; color: #065f46; }
.tone--amber { background: #fffdf2; color: #92400e; }
.tone--rose { background: #fff1f2; color: #9f1239; }
.tone--empty { background: #f8fafc; color: #64748b; }
/* 休息时间样式 */
.pause-item {
background: #f1f5f9;
border-radius: 26px;
display: flex;
align-items: center;
justify-content: center;
min-height: 80px;
opacity: 0.7;
}
.pause-tag {
font-size: 15px;
line-height: 1.35;
color: #0f172a;
font-weight: 900;
color: #94a3b8;
letter-spacing: 0.1em;
}
.timeline-event__location {
margin-top: 14px;
font-size: 12px;
color: #64748b;
/* 骨架屏 */
.skeleton-pill {
min-height: 140px;
background: #f1f5f9;
border-radius: 26px;
animation: pill-shimmer 1.5s infinite linear;
}
.timeline-event--course {
background: #eff6ff;
@keyframes pill-shimmer {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.timeline-event--course::before {
background: #3b82f6;
/* 动画效果 */
.grid-pop-enter-active {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.timeline-event--course .timeline-event__title,
.timeline-event--course .timeline-event__time {
color: #1d4ed8;
}
.timeline-event--sky {
background: #f0f9ff;
}
.timeline-event--sky::before {
background: #0ea5e9;
}
.timeline-event--sky .timeline-event__title,
.timeline-event--sky .timeline-event__time {
color: #0369a1;
}
.timeline-event--violet {
background: #f5f3ff;
}
.timeline-event--violet::before {
background: #8b5cf6;
}
.timeline-event--violet .timeline-event__title,
.timeline-event--violet .timeline-event__time {
color: #6d28d9;
}
.timeline-event--mint {
background: #ecfdf5;
}
.timeline-event--mint::before {
background: #10b981;
}
.timeline-event--mint .timeline-event__title,
.timeline-event--mint .timeline-event__time {
color: #047857;
}
.timeline-event--emerald {
background: #dcfce7;
}
.timeline-event--emerald::before {
background: #22c55e;
}
.timeline-event--emerald .timeline-event__title,
.timeline-event--emerald .timeline-event__time {
color: #15803d;
}
.timeline-event--amber {
background: #fffbeb;
}
.timeline-event--amber::before {
background: #f59e0b;
}
.timeline-event--amber .timeline-event__title,
.timeline-event--amber .timeline-event__time {
color: #b45309;
}
.timeline-event--cyan {
background: #cffafe;
}
.timeline-event--cyan::before {
background: #06b6d4;
}
.timeline-event--cyan .timeline-event__title,
.timeline-event--cyan .timeline-event__time {
color: #0e7490;
}
.timeline-event--neutral {
background: #f8fafc;
border-color: rgba(15, 23, 42, 0.05);
}
.timeline-event--neutral::before {
background: #94a3b8;
}
.timeline-placeholder {
border: 1px dashed rgba(15, 23, 42, 0.15);
background: #ffffff;
display: grid;
align-content: center;
justify-items: center;
gap: 8px;
padding: 14px 12px;
text-align: center;
color: #64748b;
}
.timeline-placeholder--pause {
background: #f8fafc;
}
.timeline-placeholder__title {
font-size: 16px;
color: #22324b;
}
.timeline-placeholder__hint {
font-size: 12px;
color: #7a889d;
line-height: 1.5;
}
.timeline-skeleton {
min-width: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 12px;
}
.timeline-skeleton__item {
background: linear-gradient(90deg, rgba(230, 236, 244, 0.8), rgba(245, 248, 252, 1), rgba(230, 236, 244, 0.8));
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s linear infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.fade-switch-enter-active,
.fade-switch-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.fade-switch-enter-from,
.fade-switch-leave-to {
.grid-pop-enter-from {
opacity: 0;
transform: translateY(4px);
}
.fade-switch-leave-to {
transform: translateY(-4px);
transform: scale(0.9);
}
@media (max-width: 1320px) {
.timeline-grid,
.timeline-skeleton {
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
}
@media (max-width: 1200px) {
.pastel-grid { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 1040px) {
.timeline-card__header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 780px) {
.timeline-grid,
.timeline-skeleton {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
@media (max-width: 800px) {
.pastel-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>

View File

@@ -6,6 +6,7 @@ import AssistantView from '@/views/AssistantView.vue'
import DashboardView from '@/views/DashboardView.vue'
import ScheduleView from '@/views/ScheduleView.vue'
import ToolTracePrototypeView from '@/views/ToolTracePrototypeView.vue'
import TaskInteractiveDemo from '@/views/TaskInteractiveDemo.vue'
const router = createRouter({
history: createWebHistory(),
@@ -14,6 +15,11 @@ const router = createRouter({
path: '/',
redirect: '/dashboard',
},
{
path: '/demo-task',
name: 'demo-task',
component: TaskInteractiveDemo,
},
{
path: '/auth',
name: 'auth',
@@ -57,8 +63,6 @@ const router = createRouter({
router.beforeEach((to) => {
const authStore = useAuthStore()
// 1. 进入受保护页面前,必须先确认 access token 是否存在。
// 2. 当前阶段只做“是否登录”的前端兜底,不在这里做 token 过期解析。
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return {
name: 'auth',

View File

@@ -5,6 +5,7 @@ export interface TaskItem {
priority_group: number
status: string
deadline: string
urgency_threshold_at?: string | null
is_completed: boolean
}
@@ -12,6 +13,7 @@ export interface TaskCreatePayload {
title: string
priority_group: number
deadline_at?: string | null
urgency_threshold_at?: string | null
}
export interface TaskCreateResult {
@@ -19,6 +21,7 @@ export interface TaskCreateResult {
title: string
priority_group: number
deadline_at?: string | null
urgency_threshold_at?: string | null
status: string
created_at: string
}
@@ -30,6 +33,18 @@ export interface TaskMutationResult {
status: string
}
export interface TaskUpdatePayload {
task_id: number
title?: string | null
priority_group?: number | null
deadline_at?: string | null
urgency_threshold_at?: string | null
}
export interface TaskDeletePayload {
task_id: number
}
export interface TaskBrief {
id: number
name: string

View File

@@ -1,27 +1,28 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
import { completeTask, createTask, getTasks, undoCompleteTask } from '@/api/task'
import { completeTask, createTask, getTasks, undoCompleteTask, updateTask, deleteTask } from '@/api/task'
import { getTodaySchedule } from '@/api/schedule'
import { useAuthStore } from '@/stores/auth'
import type { TaskItem, TodayEvent } from '@/types/dashboard'
import { formatHeaderDate } from '@/utils/date'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const pageLoading = ref(true)
const taskLoading = ref(true)
const scheduleLoading = ref(true)
const createTaskLoading = ref(false)
const saveTaskLoading = ref(false)
const logoutLoading = ref(false)
const createTaskDialogVisible = ref(false)
const dashboardLayoutRef = ref<HTMLElement | null>(null)
const taskDialogVisible = ref(false)
const isEditMode = ref(false)
const editingTaskId = ref<number | null>(null)
const dashboardMainRef = ref<HTMLElement | null>(null)
const dashboardMainInnerRef = ref<HTMLElement | null>(null)
const dashboardTopbarRef = ref<HTMLElement | null>(null)
@@ -35,13 +36,14 @@ const taskForm = reactive<{
title: string
priority_group: number
deadline_at: Date | null
urgency_threshold_at: Date | null
}>({
title: '',
priority_group: 2,
deadline_at: null,
urgency_threshold_at: null,
})
const quadrantOrder = [1, 2, 3, 4] as const
const quadrantMeta: Record<
@@ -52,25 +54,25 @@ const quadrantMeta: Record<
title: '重要且紧急',
caption: '优先处理',
tone: 'danger',
emptyText: '暂无需要立刻推进的事项',
emptyText: '暂无关键紧急任务',
},
2: {
title: '重要不紧急',
caption: '持续推进',
tone: 'primary',
emptyText: '这里适合放长期投入的关键任务',
emptyText: '这里放长期核心任务',
},
3: {
title: '简单不重要',
caption: '顺手完成',
tone: 'warning',
emptyText: '暂无高频但低价值的小任务',
emptyText: '暂无琐碎低价值任务',
},
4: {
title: '不简单不重要',
caption: '谨慎投入',
tone: 'slate',
emptyText: '这里可以放暂缓事项或后续再评估的任务',
emptyText: '这里放辅助或暂缓任务',
},
}
@@ -109,12 +111,8 @@ async function loadScheduleData() {
async function loadDashboardData() {
pageLoading.value = true
// 锁死最少加载时间,确保骨架屏平稳滑入定型后,再进行内外数据的交叉溶解
const minLoadingTimer = new Promise((resolve) => setTimeout(resolve, 800))
await Promise.allSettled([loadTasksData(), loadScheduleData(), minLoadingTimer])
pageLoading.value = false
}
@@ -135,26 +133,82 @@ async function handleTaskToggle(task: TaskItem) {
}
function openCreateTaskDialog() {
isEditMode.value = false
editingTaskId.value = null
taskForm.title = ''
taskForm.priority_group = 2
taskForm.deadline_at = null
createTaskDialogVisible.value = true
taskForm.urgency_threshold_at = null
taskDialogVisible.value = true
}
async function handleCreateTask() {
if (!taskForm.title.trim()) { ElMessage.warning('请先填写任务标题'); return }
createTaskLoading.value = true
function handleTaskEdit(task: TaskItem) {
isEditMode.value = true
editingTaskId.value = task.id
taskForm.title = task.title
taskForm.priority_group = task.priority_group
taskForm.deadline_at = task.deadline ? new Date(task.deadline) : null
taskForm.urgency_threshold_at = task.urgency_threshold_at ? new Date(task.urgency_threshold_at) : null
taskDialogVisible.value = true
}
async function handleTaskDelete(task: TaskItem) {
try {
const created = await createTask({
title: taskForm.title.trim(),
priority_group: taskForm.priority_group,
deadline_at: taskForm.deadline_at ? taskForm.deadline_at.toISOString() : null,
await ElMessageBox.confirm('确定要删除该任务吗?操作不可撤销。', '确认删除', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
roundButton: true
})
tasks.value.unshift({ id: created.id, user_id: 0, title: created.title, priority_group: created.priority_group, status: created.status, deadline: created.deadline_at ?? '', is_completed: false })
createTaskDialogVisible.value = false
ElMessage.success('任务已添加')
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '创建任务失败') }
finally { createTaskLoading.value = false }
await deleteTask(task.id)
tasks.value = tasks.value.filter(t => t.id !== task.id)
ElMessage.success('任务已成功删除')
} catch (error) {
if (error === 'cancel') return
ElMessage.error(error instanceof Error ? error.message : '任务删除失败')
}
}
async function handleSaveTask() {
if (!taskForm.title.trim()) { ElMessage.warning('请先填写任务标题'); return }
saveTaskLoading.value = true
try {
if (isEditMode.value && editingTaskId.value) {
// 执行更新交互
const updated = await updateTask({
task_id: editingTaskId.value,
title: taskForm.title.trim(),
priority_group: taskForm.priority_group,
deadline_at: taskForm.deadline_at ? taskForm.deadline_at.toISOString() : null,
urgency_threshold_at: taskForm.urgency_threshold_at ? taskForm.urgency_threshold_at.toISOString() : null,
})
const idx = tasks.value.findIndex(t => t.id === updated.id)
if (idx !== -1) tasks.value[idx] = updated
ElMessage.success('任务已更新')
} else {
// 执行创建交互
const created = await createTask({
title: taskForm.title.trim(),
priority_group: taskForm.priority_group,
deadline_at: taskForm.deadline_at ? taskForm.deadline_at.toISOString() : null,
urgency_threshold_at: taskForm.urgency_threshold_at ? taskForm.urgency_threshold_at.toISOString() : null,
})
tasks.value.unshift({
id: created.id,
user_id: 0,
title: created.title,
priority_group: created.priority_group,
status: created.status,
deadline: created.deadline_at ?? '',
is_completed: false,
urgency_threshold_at: created.urgency_threshold_at
})
ElMessage.success('任务已添加')
}
taskDialogVisible.value = false
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存任务失败') }
finally { saveTaskLoading.value = false }
}
async function handleLogout() {
@@ -164,8 +218,6 @@ async function handleLogout() {
finally { logoutLoading.value = false; await router.push('/auth') }
}
function handleCourseImportEntry() { ElMessage.info('课表导入入口已预留') }
function syncDashboardMainScale() {
const main = dashboardMainRef.value
const inner = dashboardMainInnerRef.value
@@ -241,6 +293,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
:tasks="groupedTasks[group]"
:loading="taskLoading || pageLoading"
@toggle="handleTaskToggle"
@edit="handleTaskEdit"
@delete="handleTaskDelete"
/>
</section>
@@ -261,8 +315,8 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
</section>
<el-dialog
v-model="createTaskDialogVisible"
title="添加新任务"
v-model="taskDialogVisible"
:title="isEditMode ? '编辑任务详情' : '添加新任务'"
width="440px"
align-center
class="dashboard-dialog premium-dialog"
@@ -279,21 +333,32 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
<el-option :value="4" label="4 - 不简单不重要" />
</el-select>
</el-form-item>
<el-form-item label="截止时间">
<el-date-picker
v-model="taskForm.deadline_at"
type="datetime"
placeholder="可选"
class="dashboard-dialog__select"
popper-class="premium-select-popper"
/>
</el-form-item>
<div class="dialog-double-row">
<el-form-item label="截止时间" class="half">
<el-date-picker
v-model="taskForm.deadline_at"
type="datetime"
placeholder="截止时间"
class="dashboard-dialog__select"
popper-class="premium-select-popper"
/>
</el-form-item>
<el-form-item label="紧急阈值" class="half">
<el-date-picker
v-model="taskForm.urgency_threshold_at"
type="datetime"
placeholder="进入紧急的时间点"
class="dashboard-dialog__select"
popper-class="premium-select-popper"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="premium-dialog__footer">
<button class="premium-btn premium-btn--ghost" @click="createTaskDialogVisible = false">取消</button>
<button class="premium-btn premium-btn--primary" :disabled="createTaskLoading" @click="handleCreateTask">
{{ createTaskLoading ? '保存中...' : '确认添加' }}
<button class="premium-btn premium-btn--ghost" @click="taskDialogVisible = false">取消</button>
<button class="premium-btn premium-btn--primary" :disabled="saveTaskLoading" @click="handleSaveTask">
{{ saveTaskLoading ? '保存中...' : (isEditMode ? '保存修改' : '确认添加') }}
</button>
</div>
</template>
@@ -424,6 +489,17 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
border-top: 1px solid #f1f5f9;
}
.dialog-double-row {
display: flex;
gap: 12px;
width: 100%;
overflow: hidden;
}
.dialog-double-row .half {
flex: 1;
min-width: 0; /* 关键:强制子项可收缩 */
}
.premium-btn {
height: 40px;
padding: 0 20px;
@@ -486,11 +562,13 @@ watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], a
}
:global(.premium-dialog .el-input__wrapper),
:global(.premium-dialog .el-select__wrapper) {
:global(.premium-dialog .el-select__wrapper),
:global(.premium-dialog .el-date-editor.el-input__wrapper) {
background-color: #f8fafc !important;
box-shadow: 0 0 0 1px #e2e8f0 inset !important;
border-radius: 10px !important;
padding: 4px 12px !important;
width: 100% !important;
}
:global(.premium-dialog .el-input__wrapper.is-focus),

View File

@@ -0,0 +1,356 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// --- 模拟数据 ---
const tasks = ref([
{ id: 1, title: '完成系统架构重构方案', is_completed: false, ddl: '2024-04-25T10:00:00', priority: 1 },
{ id: 2, title: '准备技术分享 PPT', is_completed: true, ddl: '2024-04-24T14:00:00', priority: 2 },
{ id: 3, title: '代码 Review用户模块', is_completed: false, ddl: '2024-04-23T18:00:00', priority: 3 },
])
const quadrantMap = {
1: { label: '重要且紧急', color: '#ef4444' },
2: { label: '重要不紧急', color: '#3b82f6' },
3: { label: '简单不重要', color: '#f59e0b' },
4: { label: '不简单不重要', color: '#64748b' },
}
// --- 交互状态 ---
const editDialogVisible = ref(false)
const currentEditingTask = reactive({
id: 0,
title: '',
ddl: '',
priority: 1
})
// 1. 切换状态 (独立响应区)
const toggleTask = (task: any) => {
task.is_completed = !task.is_completed
ElMessage.success({
message: task.is_completed ? '标记为已完成' : '已恢复为待办',
customClass: 'premium-msg'
})
}
// 2. 编辑任务 (正中主响应区)
const openEdit = (task: any) => {
currentEditingTask.id = task.id
currentEditingTask.title = task.title
currentEditingTask.ddl = task.ddl
currentEditingTask.priority = task.priority
editDialogVisible.value = true
}
// 3. 删除任务 (悬浮侧响应区)
const deleteTask = (task: any) => {
ElMessageBox.confirm('确定要删除这个任务吗?此操作不可撤销。', '确认删除', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
confirmButtonClass: 'flat-btn-danger',
cancelButtonClass: 'flat-btn-ghost',
center: true,
}).then(() => {
tasks.value = tasks.value.filter(t => t.id !== task.id)
ElMessage.success('任务已安全移除')
})
}
const saveEdit = () => {
const task = tasks.value.find(t => t.id === currentEditingTask.id)
if (task) {
task.title = currentEditingTask.title
task.ddl = currentEditingTask.ddl
task.priority = currentEditingTask.priority
editDialogVisible.value = false
ElMessage.success('任务已更新')
}
}
const formatSimpleDate = (dateStr: string) => {
if (!dateStr) return '无截止时间'
const d = new Date(dateStr)
return `${d.getMonth() + 1}-${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}
</script>
<template>
<div class="demo-page">
<div class="demo-card">
<header class="card-header">
<div class="card-title">
<p class="subtitle">UPGRADED INTERACTION</p>
<h2>全参数扁平化示例</h2>
</div>
<span class="count-badge">{{ tasks.length }} </span>
</header>
<div class="task-list">
<TransitionGroup name="task-anime">
<div
v-for="task in tasks"
:key="task.id"
class="task-item"
:class="{ 'is-completed': task.is_completed }"
>
<!-- 区域1: 独立勾选框 -->
<div class="check-box-wrapper" @click.stop="toggleTask(task)">
<div class="check-box-inner">
<svg v-if="task.is_completed" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="4">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
</div>
<!-- 区域2: 文本主体内容 (点击触发编辑) -->
<div class="task-body" @click="openEdit(task)">
<div class="task-text-row">
<span class="task-text">{{ task.title }}</span>
<span class="priority-tag" :style="{ color: quadrantMap[task.priority as keyof typeof quadrantMap].color }">
{{ quadrantMap[task.priority as keyof typeof quadrantMap].label }}
</span>
</div>
<div class="task-meta">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
{{ formatSimpleDate(task.ddl) }}
</div>
</div>
<!-- 区域3: 悬浮操作菜单 -->
<div class="hover-actions-panel">
<button class="action-btn-mini delete-btn" @click.stop="deleteTask(task)">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
</button>
</div>
</div>
</TransitionGroup>
</div>
<div class="footer-tip">快捷操作点击任务主体进行全参数编辑</div>
</div>
<!-- 深度扁平化编辑弹窗 -->
<el-dialog
v-model="editDialogVisible"
title="编辑详细参数"
width="440px"
align-center
class="flat-dialog"
:show-close="false"
>
<div class="flat-form">
<div class="form-item">
<label>任务标题</label>
<input v-model="currentEditingTask.title" class="flat-input" placeholder="输入任务标题..." />
</div>
<div class="form-row">
<div class="form-item half">
<label>优先级象限</label>
<el-select v-model="currentEditingTask.priority" popper-class="flat-select-popper" class="flat-select">
<el-option v-for="(v, k) in quadrantMap" :key="k" :label="v.label" :value="Number(k)" />
</el-select>
</div>
<div class="form-item half">
<label>截止日期</label>
<el-date-picker
v-model="currentEditingTask.ddl"
type="datetime"
placeholder="选择时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD[T]HH:mm:ss"
class="flat-picker"
popper-class="flat-picker-popper"
/>
</div>
</div>
</div>
<template #footer>
<div class="flat-footer">
<button class="flat-btn ghost" @click="editDialogVisible = false">放弃修改</button>
<button class="flat-btn primary" @click="saveEdit">保存并同步</button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 基础布局 */
.demo-page {
min-height: 100vh;
background: #fdfdfd;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.demo-card {
width: 100%;
max-width: 460px;
background: #ffffff;
border-radius: 20px;
border: 1px solid #eeeeee;
box-shadow: 0 4px 24px rgba(0,0,0,0.03);
padding: 32px;
}
.card-header {
margin-bottom: 24px;
}
.subtitle {
font-size: 10px;
font-weight: 900;
color: #94a3b8;
letter-spacing: 0.2em;
margin: 0 0 4px;
}
.card-title h2 {
font-size: 22px;
font-weight: 800;
color: #0f172a;
margin: 0;
}
.count-badge {
font-size: 12px;
color: #64748b;
font-weight: 600;
margin-top: 4px;
display: inline-block;
}
/* 列表样式 */
.task-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.task-item {
position: relative;
background: #ffffff;
border: 1px solid #f1f5f9;
border-radius: 12px;
padding: 14px 16px;
display: flex;
align-items: center;
transition: all 0.2s ease;
cursor: pointer;
}
.task-item:hover {
border-color: #e2e8f0;
background: #f8fafc;
}
.check-box-inner {
width: 22px;
height: 22px;
border-radius: 6px;
border: 2px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: #fff;
margin-right: 14px;
}
.is-completed .check-box-inner {
background: #10b981;
border-color: #10b981;
color: #fff;
}
.task-body { flex: 1; min-width: 0; }
.task-text-row { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
.task-text { font-size: 15px; font-weight: 600; color: #1e293b; }
.is-completed .task-text { color: #94a3b8; text-decoration: line-through; }
.priority-tag { font-size: 11px; font-weight: 700; background: rgba(0,0,0,0.03); padding: 2px 6px; border-radius: 4px; }
.task-meta { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 4px; }
.hover-actions-panel {
position: absolute;
right: 12px;
opacity: 0;
transition: all 0.2s;
}
.task-item:hover .hover-actions-panel { opacity: 1; }
.action-btn-mini { border: none; background: transparent; color: #ef4444; cursor: pointer; padding: 4px; border-radius: 6px; }
.action-btn-mini:hover { background: #fee2e2; }
/* 深度扁平化弹窗 - 关键美化部分 */
:global(.flat-dialog) {
border-radius: 16px !important;
border: 1px solid #f1f5f9 !important;
box-shadow: 0 20px 40px rgba(0,0,0,0.1) !important;
}
:global(.flat-dialog .el-dialog__header) {
padding: 24px 28px 10px !important;
text-align: left;
}
:global(.flat-dialog .el-dialog__title) {
font-size: 16px !important;
font-weight: 800 !important;
color: #0f172a;
}
:global(.flat-dialog .el-dialog__body) {
padding: 10px 28px 24px !important;
}
.flat-form { display: flex; flex-direction: column; gap: 20px; }
.form-row { display: flex; gap: 16px; }
.form-item { display: flex; flex-direction: column; gap: 8px; }
.form-item.half { flex: 1; }
.form-item label { font-size: 12px; font-weight: 800; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
/* 纯扁平输入框 */
.flat-input {
height: 42px;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0 14px;
background: #f8fafc;
outline: none;
font-size: 14px;
transition: all 0.2s;
font-weight: 600;
}
.flat-input:focus { border-color: #3b82f6; background: #fff; }
/* Element Plus 深度覆盖 */
:global(.flat-select .el-select__wrapper),
:global(.flat-picker.el-input__wrapper) {
background-color: #f8fafc !important;
box-shadow: none !important;
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
height: 42px !important;
}
.flat-footer { display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid #f1f5f9; padding-top: 20px; }
.flat-btn { height: 42px; padding: 0 20px; border-radius: 10px; font-weight: 700; font-size: 13px; cursor: pointer; border: none; transition: all 0.2s; }
.flat-btn.primary { background: #0f172a; color: #fff; }
.flat-btn.primary:hover { background: #334155; }
.flat-btn.ghost { background: transparent; color: #64748b; }
.flat-btn.ghost:hover { background: #f1f5f9; }
.footer-tip { margin-top: 24px; font-size: 12px; color: #94a3b8; text-align: center; }
/* 动画 */
.task-anime-enter-active { transition: all 0.3s ease; }
.task-anime-enter-from { opacity: 0; transform: scale(0.95); }
.task-anime-leave-to { opacity: 0; transform: scale(1.05); }
</style>