gwy-exam/client/src/views/plan/PlanView.vue

696 lines
21 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, onMounted, ref } from 'vue'
import { plansApi, profileApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useResponsive } from '../../composables/useResponsive'
import { useAppStore } from '../../stores/app'
import { formatMinutes, formatDateShort, weekdayName, daysSince } from '../../utils/format'
import AppCard from '../../components/base/AppCard.vue'
import AppButton from '../../components/base/AppButton.vue'
import AppBadge from '../../components/base/AppBadge.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import AppModal from '../../components/base/AppModal.vue'
import AppPageHeader from '../../components/base/AppPageHeader.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
const { isMobile } = useResponsive()
const app = useAppStore()
const today = useRequest(() => plansApi.today())
const week = useRequest(() => plansApi.week())
const review = useRequest(() => plansApi.review())
// 新建任务表单
const showCreate = ref(false)
const saving = ref(false)
const form = ref({ title: '', type: '刷题', module: '', target: '', note: '' })
const MODULE_OPTIONS = ['言语理解', '数量关系', '判断推理', '资料分析', '常识判断'] as const
const examProfile = useRequest(() => profileApi.get(), false)
onMounted(() => {
if (!examProfile.data.value) examProfile.refresh()
})
const todayData = computed(() => today.data.value)
const weekDays = computed(() => week.data.value?.days ?? [])
const reviewData = computed(() => review.data.value)
// 顶部 4 指标(桌面)
const weekMetric = computed(() => {
const w = weekDays.value
const total = w.reduce((sum, d) => sum + d.total, 0)
const done = w.reduce((sum, d) => sum + d.done, 0)
return { total, done }
})
const weekCompletion = computed(() => {
const m = weekMetric.value
return m.total === 0 ? 0 : Math.round((m.done / m.total) * 100)
})
const studyMinutes = computed(() => reviewData.value?.studyMinutes ?? 0)
const streakDays = computed(() => reviewData.value?.streakDays ?? 0)
const weekNumber = computed(() => reviewData.value?.weekNumber ?? 1)
// 明日预告
const tomorrowTasks = computed(() => {
const target = new Date()
target.setDate(target.getDate() + 1)
const iso = `${target.getFullYear()}-${String(target.getMonth() + 1).padStart(2, '0')}-${String(target.getDate()).padStart(2, '0')}`
return weekDays.value.find((d) => d.date === iso)?.tasks ?? []
})
const planMessage = computed(() => {
const d = todayData.value
if (!d) return ''
return `${formatDateShort(d.date)} ${weekdayName(d.date)} · 完成 ${d.done}/${d.total}`
})
const todayProgress = computed(() => {
const d = todayData.value
if (!d || d.total === 0) return 0
return Math.round((d.done / d.total) * 100)
})
// 本周计划进度(移动)
const weekProgress = computed(() => weekCompletion.value)
const weekAnswered = computed(() => reviewData.value?.answered ?? 0)
const weekTargetAnswered = computed(() => Math.max(weekAnswered.value, Math.max(weekMetric.value.total * 10, 10)))
const weekAnsweredPercent = computed(() => {
const t = weekTargetAnswered.value
return t === 0 ? 0 : Math.min(100, Math.round((weekAnswered.value / t) * 100))
})
const taskTypeTone = (type: string) =>
(type ?? '').includes('复习') || (type ?? '').includes('复盘') ? 'warning' : 'soft'
const taskTypeLabel = (type: string) => (type && type.length > 6 ? type.slice(0, 6) : type || '任务')
async function toggle(task: { id: string; status: string }) {
try {
const result = await plansApi.toggleTask(task.id)
app.toast(result.status === 'done' ? '任务已完成' : '已恢复为待完成', 'success')
today.refresh()
week.refresh()
review.refresh()
} catch (err) {
app.toast(err instanceof Error ? err.message : '操作失败', 'error')
}
}
async function createTask() {
const f = form.value
if (!f.title.trim() || !f.target.trim()) {
app.toast('请填写任务标题与目标', 'error')
return
}
saving.value = true
try {
const date = new Date()
const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
await plansApi.createTask({
date: iso,
title: f.title.trim(),
type: f.type,
...(f.module ? { module: f.module as '言语理解' | '数量关系' | '判断推理' | '资料分析' | '常识判断' } : {}),
target: f.target.trim(),
...(f.note ? { note: f.note } : {})
})
app.toast('任务已创建', 'success')
showCreate.value = false
form.value = { title: '', type: '刷题', module: '', target: '', note: '' }
today.refresh()
week.refresh()
review.refresh()
} catch (err) {
app.toast(err instanceof Error ? err.message : '创建失败', 'error')
} finally {
saving.value = false
}
}
const profileDays = computed(() => (examProfile.data.value ? daysSince(examProfile.data.value.startedAt) : 0))
</script>
<template>
<div class="plan">
<AppLoading v-if="today.loading.value" fullscreen />
<AppError
v-else-if="today.error.value"
fullscreen
title="计划加载失败"
:message="today.error.value"
retry
@retry="today.refresh"
/>
<!-- ============ 移动端今日计划 ============ -->
<div v-else-if="isMobile && todayData" class="plan-mobile">
<header class="plan-mobile__head">
<h1>今日计划</h1>
<p>{{ planMessage }}</p>
</header>
<template v-if="todayData.total > 0">
<div class="plan-list">
<AppCard
v-for="task in todayData.tasks"
:key="task.id"
class="plan-task"
:class="{ 'is-done': task.status === 'done' }"
>
<AppButton
class="plan-task__check"
:class="{ 'is-done': task.status === 'done' }"
variant="ghost"
size="md"
:aria-label="task.status === 'done' ? '标记为待完成' : '标记为完成'"
@click="toggle(task)"
>
<AppIcon v-if="task.status === 'done'" name="check" :size="16" />
</AppButton>
<div class="plan-task__body">
<p class="plan-task__title">{{ task.title }}</p>
<p class="plan-task__meta">
{{ task.type }}<template v-if="task.module"> · {{ task.module }}</template> · {{ task.target }}
</p>
</div>
<AppBadge class="plan-task__badge" :variant="taskTypeTone(task.type)">{{
taskTypeLabel(task.type)
}}</AppBadge>
</AppCard>
</div>
<div class="plan-add">
<AppButton variant="secondary" block @click="showCreate = true">
<AppIcon name="check" :size="16" />新建任务
</AppButton>
</div>
<!-- 本周计划 -->
<AppCard class="plan-week">
<div class="plan-week__head">
<h3>本周计划</h3>
<span class="plan-week__tag">第 {{ reviewData?.weekNumber ?? 1 }} 周</span>
</div>
<div class="plan-week__meta">
<span>完成 {{ weekMetric.done }} 项 / 目标 {{ weekMetric.total }} 项</span>
</div>
<div class="progress">
<div class="progress__bar" :style="{ width: `${weekProgress}%` }" />
</div>
<span class="plan-week__percent">{{ weekProgress }}%</span>
</AppCard>
<!-- 周报复盘入口 -->
<RouterLink class="plan-review-link" to="/plan/review">
<span>本周复盘 · 查看学习表现与薄弱考点</span>
<AppIcon name="chevron-right" :size="16" />
</RouterLink>
<!-- 明日预告 -->
<AppCard v-if="tomorrowTasks.length > 0" title="明日预告">
<div class="plan-tomorrow__row" v-for="task in tomorrowTasks" :key="task.id">
<span class="plan-tomorrow__dot" />
<div class="plan-tomorrow__body">
<p class="plan-tomorrow__title">{{ task.title }}</p>
<p class="plan-tomorrow__meta">{{ task.type }}<template v-if="task.module"> · {{ task.module }}</template> · {{ task.target }}</p>
</div>
</div>
</AppCard>
</template>
<!-- 今日空状态 -->
<AppEmpty
v-else
icon="calendar"
title="今天还没有任务"
description="按早/午/晚时段安排,碎片时间不浪费。"
>
<AppButton variant="primary" @click="showCreate = true">创建今日任务</AppButton>
</AppEmpty>
</div>
<!-- ============ 桌面端备考计划 ============ -->
<div v-else-if="todayData" class="plan-desktop">
<AppPageHeader title="备考计划" :subtitle="`安排阶段任务,稳步推进备考进度 · 备考第 ${profileDays} 天`" sticky-mobile>
<RouterLink class="btn-link" to="/plan/review">
<AppIcon name="chart" :size="16" />周报复盘
</RouterLink>
<AppButton variant="primary" @click="showCreate = true">
<AppIcon name="check" :size="16" />新建任务
</AppButton>
</AppPageHeader>
<!-- 4 指标卡 -->
<div class="plan-metrics">
<div class="plan-metric">
<span class="plan-metric__label">本周任务</span>
<strong class="plan-metric__value">{{ weekMetric.total }} 项</strong>
<span class="plan-metric__sub">已完成 {{ weekMetric.done }} 项</span>
</div>
<div class="plan-metric">
<span class="plan-metric__label">本周学习时长</span>
<strong class="plan-metric__value">{{ formatMinutes(studyMinutes) }}</strong>
<span class="plan-metric__sub">每日目标循序渐进</span>
</div>
<div class="plan-metric">
<span class="plan-metric__label">本周完成率</span>
<strong class="plan-metric__value">{{ weekCompletion }}%</strong>
<span class="plan-metric__sub">较上周持续提升</span>
</div>
<div class="plan-metric">
<span class="plan-metric__label">连续打卡</span>
<strong class="plan-metric__value">{{ streakDays }} 天</strong>
<span class="plan-metric__sub">本周第 {{ weekNumber }} 周</span>
</div>
</div>
<div class="plan-grid">
<!-- 今日任务 -->
<AppCard class="plan-today" title="今日任务" icon="calendar">
<AppEmpty
v-if="todayData.total === 0"
icon="calendar"
title="今天还没有任务"
description="点击右上角新建今日任务,安排阶段节奏。"
/>
<ul v-else class="plan-today__list">
<li
v-for="task in todayData.tasks"
:key="task.id"
class="plan-today__item"
:class="{ 'is-done': task.status === 'done' }"
>
<AppButton
class="plan-today__check"
:class="{ 'is-done': task.status === 'done' }"
variant="ghost"
size="md"
:aria-label="task.status === 'done' ? '标记为待完成' : '标记为完成'"
@click="toggle(task)"
>
<AppIcon v-if="task.status === 'done'" name="check" :size="14" />
</AppButton>
<span class="plan-today__title">{{ task.title }} · {{ task.target }}</span>
<AppBadge v-if="task.status === 'done'" variant="success">已完成</AppBadge>
<AppBadge v-else class="plan-today__pending">待完成</AppBadge>
</li>
</ul>
</AppCard>
<!-- 本周安排 -->
<AppCard class="plan-side" title="本周安排" icon="book">
<AppEmpty v-if="weekDays.length === 0" icon="calendar" title="暂无本周任务" />
<div v-else class="plan-side__list">
<div v-for="day in weekDays" :key="day.date" class="plan-side__day">
<p class="plan-side__date">{{ formatDateShort(day.date) }} · {{ weekdayName(day.date) }}</p>
<ul v-if="day.tasks.length > 0" class="plan-side__tasks">
<li v-for="task in day.tasks" :key="task.id" :class="{ 'is-done': task.status === 'done' }">
{{ task.title }}
</li>
</ul>
<p v-else class="plan-side__blank">暂无安排</p>
</div>
</div>
</AppCard>
</div>
</div>
<!-- 新建任务弹层 -->
<AppModal v-if="showCreate" title="新建计划任务" :max-width="480" @close="showCreate = false">
<div class="form-stack">
<label class="field">
<span class="field__label">任务标题</span>
<input v-model="form.title" class="field__input" placeholder="如:言语理解专项" />
</label>
<label class="field">
<span class="field__label">任务类型</span>
<select v-model="form.type" class="field__input">
<option value="刷题">刷题</option>
<option value="阅读">阅读</option>
<option value="复习">复习</option>
<option value="模考">模考</option>
</select>
</label>
<div class="field-grid">
<label class="field">
<span class="field__label">模块(可选)</span>
<select v-model="form.module" class="field__input">
<option value="">不指定</option>
<option v-for="m in MODULE_OPTIONS" :key="m" :value="m">{{ m }}</option>
</select>
</label>
<label class="field">
<span class="field__label">目标</span>
<input v-model="form.target" class="field__input" placeholder="如:完成 20 题" />
</label>
</div>
<label class="field">
<span class="field__label">备注可选</span>
<textarea v-model="form.note" class="field__input field__textarea" placeholder="补充说明" />
</label>
</div>
<template #footer>
<AppButton variant="ghost" @click="showCreate = false">取消</AppButton>
<AppButton variant="primary" :disabled="saving" @click="createTask">
{{ saving ? '保存中' : '保存任务' }}
</AppButton>
</template>
</AppModal>
</div>
</template>
<style scoped>
.plan {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
/* 指标卡 */
.plan-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--card-gap-desktop);
}
.plan-metric {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--card-padding-desktop);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.plan-metric__label {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.plan-metric__value {
font-family: var(--font-num);
font-size: var(--fs-data-num);
font-weight: var(--fw-data-num);
color: var(--text-primary);
}
.plan-metric__sub {
font-size: var(--fs-label);
color: var(--text-muted);
}
.plan-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--card-gap-desktop);
align-items: start;
}
/* 今日任务列表 */
.plan-today__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.plan-today__item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.plan-today__item.is-done {
background: var(--success-soft);
border-color: transparent;
}
.plan-today__check {
display: grid !important;
place-items: center;
width: 20px !important;
height: 20px !important;
flex: none;
padding: 0 !important;
border-radius: 50% !important;
border: 1.5px solid var(--text-muted) !important;
background: transparent !important;
color: var(--text-muted) !important;
gap: 0 !important;
cursor: pointer;
}
.plan-today__check.is-done {
background: var(--success) !important;
border-color: var(--success) !important;
color: #fff !important;
}
.plan-today__title {
flex: 1;
font-size: var(--fs-subtitle);
color: var(--text-primary);
}
.plan-today__pending {
color: var(--text-muted);
background: var(--bg-soft);
display: inline-flex;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--fs-label);
}
/* 本周安排 */
.plan-side__list {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.plan-side__date {
margin: 0 0 var(--space-1);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
color: var(--text-secondary);
}
.plan-side__tasks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.plan-side__tasks li {
font-size: var(--fs-subtitle);
color: var(--text-primary);
}
.plan-side__tasks li.is-done {
color: var(--text-muted);
text-decoration: line-through;
}
.plan-side__blank {
margin: 0;
font-size: var(--fs-label);
color: var(--text-muted);
}
/* 移动端 */
.plan-mobile {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.plan-mobile__head h1 {
margin: 0;
font-size: 22px;
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.plan-mobile__head p {
margin: var(--space-1) 0 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.plan-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.plan-task {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--card-padding-mobile);
}
.plan-task.is-done {
background: var(--success-soft);
border-color: transparent;
}
.plan-task__check {
display: grid !important;
place-items: center;
width: 26px !important;
height: 26px !important;
flex: none;
padding: 0 !important;
border-radius: 50% !important;
border: 1.5px solid var(--text-muted) !important;
background: transparent !important;
color: var(--text-muted) !important;
gap: 0 !important;
cursor: pointer;
}
.plan-task__check.is-done {
background: var(--success) !important;
border-color: var(--success) !important;
color: #fff !important;
}
.plan-task__body {
flex: 1;
min-width: 0;
}
.plan-task__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.plan-task__meta {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
.plan-task__badge {
flex: none;
}
.plan-add {
margin-top: var(--space-1);
}
.plan-week {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.plan-week__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.plan-week__head h3 {
margin: 0;
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.plan-week__tag {
font-size: var(--fs-label);
color: var(--text-muted);
}
.plan-week__meta {
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.progress {
height: 8px;
border-radius: var(--radius-pill);
background: var(--bg-soft);
overflow: hidden;
}
.progress__bar {
height: 100%;
border-radius: var(--radius-pill);
background: var(--primary);
transition: width var(--motion-switch) var(--ease-default);
}
.plan-week__percent {
align-self: flex-end;
font-size: var(--fs-label);
color: var(--primary);
font-weight: var(--fw-label-bold);
}
.plan-review-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background: var(--bg-soft);
border-radius: var(--radius-lg);
color: var(--primary);
font-size: var(--fs-subtitle);
text-decoration: none;
}
.plan-tomorrow__row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
}
.plan-tomorrow__dot {
width: 10px;
height: 10px;
flex: none;
border-radius: 50%;
background: var(--bg-soft);
}
.plan-tomorrow__title {
margin: 0;
font-size: var(--fs-subtitle);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.plan-tomorrow__meta {
margin: 2px 0 0;
font-size: var(--fs-label);
color: var(--text-secondary);
}
@media (max-width: 1023px) {
.plan-metrics {
grid-template-columns: repeat(2, 1fr);
}
.plan-grid {
grid-template-columns: 1fr;
}
}
/* 弹层表单字段堆叠 */
.form-stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.field__label {
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
color: var(--text-secondary);
}
.field__input {
height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-btn);
background: var(--bg-card);
color: var(--text-primary);
font-size: var(--fs-subtitle);
}
.field__input:focus {
outline: none;
border-color: var(--primary);
}
.field__textarea {
height: auto;
min-height: 72px;
padding: var(--space-3);
resize: vertical;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
</style>