Files
smartmate/frontend/src/views/DashboardView.vue
2026-05-06 00:30:08 +08:00

661 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import TaskQuadrantCard from '@/components/dashboard/TaskQuadrantCard.vue'
import TodayTimeline from '@/components/dashboard/TodayTimeline.vue'
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 authStore = useAuthStore()
const pageLoading = ref(true)
const taskLoading = ref(true)
const scheduleLoading = ref(true)
const saveTaskLoading = ref(false)
const logoutLoading = ref(false)
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)
const dashboardContentRef = ref<HTMLElement | null>(null)
const dashboardMainScale = ref(1)
const tasks = ref<TaskItem[]>([])
const todayEvents = ref<TodayEvent[]>([])
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<
(typeof quadrantOrder)[number],
{ title: string; caption: string; tone: 'danger' | 'primary' | 'warning' | 'slate'; emptyText: string }
> = {
1: {
title: '重要且紧急',
caption: '优先处理',
tone: 'danger',
emptyText: '暂无关键紧急任务',
},
2: {
title: '重要不紧急',
caption: '持续推进',
tone: 'primary',
emptyText: '这里放长期核心任务',
},
3: {
title: '简单不重要',
caption: '顺手完成',
tone: 'warning',
emptyText: '暂无琐碎低价值任务',
},
4: {
title: '不简单不重要',
caption: '谨慎投入',
tone: 'slate',
emptyText: '这里放辅助或暂缓任务',
},
}
const pageTitleDate = computed(() => formatHeaderDate(new Date()))
const greetingName = computed(() => authStore.lastUsername || 'SmartMate 用户')
const groupedTasks = computed(() => {
const groups: Record<number, TaskItem[]> = { 1: [], 2: [], 3: [], 4: [] }
for (const task of tasks.value) {
if (groups[task.priority_group]) groups[task.priority_group].push(task)
}
for (const key of Object.keys(groups)) {
groups[Number(key)].sort((left, right) => {
if (left.is_completed !== right.is_completed) return left.is_completed ? 1 : -1
return left.id - right.id
})
}
return groups
})
async function loadTasksData() {
taskLoading.value = true
try { tasks.value = await getTasks() }
catch (error) { ElMessage.warning(error instanceof Error ? error.message : '任务加载失败') }
finally { taskLoading.value = false }
}
async function loadScheduleData() {
scheduleLoading.value = true
try {
const schedules = await getTodaySchedule()
todayEvents.value = schedules.flatMap((item) => item.events).sort((left, right) => left.order - right.order)
} catch (error) { ElMessage.warning(error instanceof Error ? error.message : '今日日程加载失败') }
finally { scheduleLoading.value = false }
}
async function loadDashboardData() {
pageLoading.value = true
const minLoadingTimer = new Promise((resolve) => setTimeout(resolve, 800))
await Promise.allSettled([loadTasksData(), loadScheduleData(), minLoadingTimer])
pageLoading.value = false
}
async function handleTaskToggle(task: TaskItem) {
try {
if (task.is_completed) {
const result = await undoCompleteTask(task.id)
task.is_completed = result.is_completed
task.status = result.status
ElMessage.success('任务已恢复为未完成')
return
}
const result = await completeTask(task.id)
task.is_completed = result.is_completed
task.status = result.status
ElMessage.success(result.already_completed ? '任务已经是完成状态' : '任务已标记为完成')
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '任务更新失败') }
}
function openCreateTaskDialog() {
isEditMode.value = false
editingTaskId.value = null
taskForm.title = ''
taskForm.priority_group = 2
taskForm.deadline_at = null
taskForm.urgency_threshold_at = null
taskDialogVisible.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 {
await ElMessageBox.confirm('确定要删除该任务吗?操作不可撤销。', '确认删除', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
roundButton: true
})
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() {
logoutLoading.value = true
try { await authStore.logout(); ElMessage.success('已安全退出登录') }
catch (error) { ElMessage.warning(error instanceof Error ? `${error.message},本地登录态已清除` : '退出接口异常,本地登录态已清除') }
finally { logoutLoading.value = false; await router.push('/auth') }
}
function handleCourseImportEntry() {
void router.push('/schedule')
}
function syncDashboardMainScale() {
const main = dashboardMainRef.value
const inner = dashboardMainInnerRef.value
const topbar = dashboardTopbarRef.value
const content = dashboardContentRef.value
if (!main || !inner || !topbar || !content || window.innerWidth <= 980) { dashboardMainScale.value = 1; return }
dashboardMainScale.value = 1
window.requestAnimationFrame(() => {
const availableHeight = main.clientHeight
const gridGap = 10
const naturalHeight = topbar.getBoundingClientRect().height + content.scrollHeight + gridGap
if (!availableHeight || !naturalHeight) { dashboardMainScale.value = 1; return }
const nextScale = Math.min(1, (availableHeight / naturalHeight) * 0.96)
dashboardMainScale.value = Number(nextScale.toFixed(4))
})
}
onMounted(async () => {
await loadDashboardData()
await nextTick()
syncDashboardMainScale()
window.addEventListener('resize', syncDashboardMainScale)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', syncDashboardMainScale)
})
watch([() => tasks.value.length, () => todayEvents.value.length, pageLoading], async () => {
await nextTick()
syncDashboardMainScale()
}, { flush: 'post' })
</script>
<template>
<section ref="dashboardMainRef" class="dashboard-main">
<div ref="dashboardMainInnerRef" class="dashboard-main__scaled" :style="{ '--dashboard-main-scale': dashboardMainScale }">
<header ref="dashboardTopbarRef" class="dashboard-topbar glass-panel dashboard-item-pop" :style="{ '--anim-delay': '0s' }">
<div>
<div class="dashboard-topbar__brandline">
<strong>AI 智慧日程系统</strong>
<span>{{ pageTitleDate }}</span>
</div>
</div>
<div class="dashboard-topbar__actions">
<button type="button" class="dashboard-topbar__logout" :disabled="logoutLoading" @click="handleLogout">{{ logoutLoading ? '退出中...' : '登出' }}</button>
<div class="dashboard-topbar__profile">
<strong>{{ greetingName }}</strong>
<span>{{ greetingName.slice(0, 1).toUpperCase() }}</span>
</div>
</div>
</header>
<div ref="dashboardContentRef" class="dashboard-content page-shell">
<TodayTimeline :style="{ '--anim-delay': '0.04s' }" :events="todayEvents" :loading="scheduleLoading || pageLoading" />
<div class="dashboard-actions dashboard-item-pop" :style="{ '--anim-delay': '0.08s' }">
<button type="button" class="dashboard-actions__primary" @click="openCreateTaskDialog">添加任务</button>
</div>
<section class="dashboard-quadrants">
<TaskQuadrantCard
v-for="(group, index) in quadrantOrder"
:key="group"
class="dashboard-item-pop"
:style="{ '--anim-delay': (0.12 + index * 0.04) + 's' }"
:title="quadrantMeta[group].title"
:caption="quadrantMeta[group].caption"
:tone="quadrantMeta[group].tone"
:empty-text="quadrantMeta[group].emptyText"
:count="groupedTasks[group].length"
:tasks="groupedTasks[group]"
:loading="taskLoading || pageLoading"
@toggle="handleTaskToggle"
@edit="handleTaskEdit"
@delete="handleTaskDelete"
/>
</section>
<section class="dashboard-import glass-panel dashboard-item-pop" :style="{ '--anim-delay': '0.28s' }">
<div class="dashboard-import__content">
<p class="dashboard-import__eyebrow">课程导入</p>
<h2>导入课表</h2>
<p>导入课表后可以在安排日程时避开上课时间</p>
<button type="button" class="dashboard-import__button" @click="handleCourseImportEntry">开始导入</button>
</div>
<div class="dashboard-import__shape">
<span class="dashboard-import__shape-ring" />
<span class="dashboard-import__shape-core" />
</div>
</section>
</div>
</div>
</section>
<el-dialog
v-model="taskDialogVisible"
:title="isEditMode ? '编辑任务详情' : '添加新任务'"
width="440px"
align-center
class="dashboard-dialog premium-dialog"
>
<el-form label-position="top">
<el-form-item label="任务标题">
<el-input v-model="taskForm.title" maxlength="255" placeholder="例如:完成数据库复习" />
</el-form-item>
<el-form-item label="优先级象限">
<el-select v-model="taskForm.priority_group" class="dashboard-dialog__select" popper-class="premium-select-popper" placement="bottom-start">
<el-option :value="1" label="1 - 重要且紧急" />
<el-option :value="2" label="2 - 重要不紧急" />
<el-option :value="3" label="3 - 简单不重要" />
<el-option :value="4" label="4 - 不简单不重要" />
</el-select>
</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="taskDialogVisible = false">取消</button>
<button class="premium-btn premium-btn--primary" :disabled="saveTaskLoading" @click="handleSaveTask">
{{ saveTaskLoading ? '保存中...' : (isEditMode ? '保存修改' : '确认添加') }}
</button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(15, 23, 42, 0.08); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: rgba(15, 23, 42, 0.15); }
@keyframes dashboard-item-fade-in {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
.dashboard-item-pop {
animation: dashboard-item-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: var(--anim-delay, 0s);
--anim-delay: 0s;
transform-origin: center center;
}
.dashboard-main { min-width: 0; min-height: 0; overflow: hidden; height: 100%; }
.dashboard-main__scaled {
--dashboard-main-scale: 1;
width: calc(100% / var(--dashboard-main-scale));
height: calc(100% / var(--dashboard-main-scale));
display: grid;
grid-template-rows: auto 1fr;
align-content: start;
gap: 10px;
transform: scale(var(--dashboard-main-scale));
transform-origin: top left;
}
.dashboard-topbar {
border-radius: 20px;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.05);
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.03);
flex-shrink: 0;
max-height: 72px; /* 锁定高度,防止在布局缩放时发生形变 */
}
.dashboard-topbar__brandline { display: flex; align-items: center; gap: 14px; }
.dashboard-topbar__brandline strong { font-size: 18px; color: #14233a; }
.dashboard-topbar__brandline span { color: #677588; font-size: 13px; }
.dashboard-topbar__actions { display: flex; align-items: center; gap: 14px; }
.dashboard-topbar__logout { min-width: 88px; height: 38px; border-radius: 13px; border: 1px solid rgba(28, 98, 205, 0.22); background: #f9fbff; color: #1d63cf; cursor: pointer; }
.dashboard-topbar__profile { display: flex; align-items: center; gap: 10px; }
.dashboard-topbar__profile strong { font-size: 13px; }
.dashboard-topbar__profile span { width: 38px; height: 38px; border-radius: 999px; background: #eef3fb; color: #314156; display: inline-flex; align-items: center; justify-content: center; font-weight: 800; }
.dashboard-content { width: 100%; display: grid; gap: 14px; align-content: start; }
.dashboard-actions { display: flex; justify-content: flex-end; }
.dashboard-actions__primary { height: 42px; padding: 0 20px; border: none; border-radius: 15px; background: #3b82f6; color: #fff; font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); }
.dashboard-quadrants { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.dashboard-import {
border-radius: 20px;
padding: 24px 32px;
min-height: 180px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.05);
box-shadow: 0 4px 15px rgba(15, 23, 42, 0.02);
display: flex;
justify-content: space-between;
gap: 24px;
position: relative;
overflow: hidden;
}
.dashboard-import__content { position: relative; z-index: 1; max-width: 460px; }
.dashboard-import__eyebrow { margin: 0 0 10px; color: #3b82f6; text-transform: uppercase; font-size: 12px; font-weight: 700; }
.dashboard-import h2 { margin: 0; font-size: 24px; color: #0f172a; font-weight: 800; }
.dashboard-import p { margin: 8px 0 16px; color: #64748b; font-size: 13px; line-height: 1.5; }
.dashboard-import__button { height: 44px; padding: 0 24px; border: none; border-radius: 12px; background: #3b82f6; color: #ffffff; font-weight: 700; cursor: pointer; }
.dashboard-import__shape { position: absolute; right: -50px; bottom: -50px; width: 220px; height: 220px; opacity: 0.1; pointer-events: none; }
.dashboard-import__shape-ring { position: absolute; inset: 0; border: 40px solid #3b82f6; border-radius: 50%; }
.dashboard-import__shape-core { position: absolute; inset: 80px; background: #3b82f6; border-radius: 50%; }
/* --- Premium Dialog Styles --- */
:global(.premium-dialog) {
border-radius: 20px !important;
background: #ffffff !important;
border: 1px solid rgba(15, 23, 42, 0.08) !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
overflow: hidden;
}
:global(.premium-dialog .el-dialog__header) {
padding: 24px 28px 12px !important;
margin-right: 0 !important;
text-align: left;
}
:global(.premium-dialog .el-dialog__title) {
font-size: 18px !important;
font-weight: 800 !important;
color: #0f172a !important;
letter-spacing: -0.02em;
}
:global(.premium-dialog .el-dialog__body) {
padding: 12px 28px 20px !important;
}
:global(.premium-dialog .el-dialog__footer) {
padding: 0 !important;
}
.premium-dialog__footer {
padding: 16px 28px 24px;
background: #f8fafc;
display: flex;
justify-content: flex-end;
gap: 12px;
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;
border-radius: 12px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.premium-btn--primary {
background: #3b82f6;
color: #ffffff;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.premium-btn--primary:hover:not(:disabled) {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.3);
}
.premium-btn--ghost {
background: #ffffff;
color: #64748b;
border: 1px solid #e2e8f0;
}
.premium-btn--ghost:hover {
background: #f1f5f9;
color: #0f172a;
}
/* 弹出动画覆写 */
:global(.dialog-fade-enter-active .premium-dialog) {
animation: premium-dialog-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes premium-dialog-fade-in {
0% { opacity: 0; transform: scale(0.98) translateY(10px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
:global(.el-overlay) {
backdrop-filter: blur(6px);
background: rgba(15, 23, 42, 0.35) !important;
}
/* 表单美化 */
:global(.premium-dialog .el-form-item__label) {
font-weight: 700 !important;
color: #475569 !important;
font-size: 13px !important;
margin-bottom: 6px !important;
}
:global(.premium-dialog .el-input__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),
:global(.premium-dialog .el-select__wrapper.is-focused) {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) inset !important;
background-color: #ffffff !important;
}
:global(.premium-dialog .el-dialog__headerbtn) {
top: 20px !important;
right: 20px !important;
width: 32px !important;
height: 32px !important;
border-radius: 50% !important;
background: #f1f5f9 !important;
transition: all 0.2s !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
:global(.premium-dialog .el-dialog__headerbtn:hover) {
background: #e2e8f0 !important;
transform: rotate(90deg);
}
:global(.premium-dialog .el-dialog__headerbtn .el-dialog__close) {
color: #64748b !important;
font-size: 16px !important;
font-weight: 800 !important;
}
/* 统一输入框高度与背景 */
:global(.premium-dialog .el-input__inner),
:global(.premium-dialog .el-select .el-input__inner) {
height: 38px !important;
color: #0f172a !important;
font-weight: 600 !important;
}
/* --- 下拉菜单扁平化 --- */
:global(.premium-select-popper) {
border-radius: 16px !important;
border: 1px solid rgba(15, 23, 42, 0.08) !important;
box-shadow: 0 12px 30px -5px rgba(0, 0, 0, 0.12) !important;
background: #ffffff !important;
overflow: hidden !important;
margin-top: 8px !important;
}
:global(.premium-select-popper .el-select-dropdown__list) {
padding: 6px !important;
}
:global(.premium-select-popper .el-select-dropdown__item) {
border-radius: 10px !important;
height: 38px !important;
line-height: 38px !important;
margin-bottom: 2px !important;
font-weight: 600 !important;
color: #475569 !important;
padding: 0 12px !important;
}
:global(.premium-select-popper .el-select-dropdown__item.is-selected) {
background: #eff6ff !important;
color: #3b82f6 !important;
}
:global(.premium-select-popper .el-select-dropdown__item:hover) {
background: #f1f5f9 !important;
color: #0f172a !important;
}
:global(.premium-select-popper .el-popper__arrow) {
display: none !important;
}
/* 时间选择器特定深度覆盖 */
:global(.premium-select-popper.el-picker-popper) {
padding: 0 !important;
}
:global(.premium-select-popper .el-picker-panel) {
background: transparent !important;
}
</style>