Files
lpt-fe/src/components/Study.vue
T
cat-shark ea9b7ec569 feat(会话):跨页面自动恢复 + 阻止多任务同时进行
后端:
- StudySessionsServiceImpl.getActiveSession(excludeTaskNum):
  查询 ONGOING/PAUSED 状态的活跃会话,可排除指定任务
- Controller 新增 GET /study-sessions/active?excludeTaskNum=

前端:
- router beforeEach 守卫:读取 localStorage 中 activeSession,
  非 start-task 页面时自动重定向恢复
- Study.vue 开始任务按钮:调用 getActiveSession() 检测是否
  有其他任务正在进行,提示用户前往继续
- StartTask.vue loadTaskSession 成功后将活跃会话写入
  localStorage(taskNum/sessionNum/taskName)
- endSession 成功时清除 localStorage 中的 activeSession
- 路由参数 ?resume=true 时页面顶部显示恢复提示已恢复上次
2026-07-05 11:15:22 +08:00

748 lines
20 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, watch } from "vue";
import request from "@/utils/request";
import router from "@/router";
import { ElMessage, ElMessageBox } from "element-plus";
import { getReviewTaskStatsByTask } from "@/api/review";
import { getActiveSession } from "@/api/studySessions";
import {
getPriorityWeights,
getTaskApplications,
savePriorityWeights,
updateTaskApplication,
type PriorityWeights,
type TaskApplication,
} from "@/api/tasks";
import { fetchTitles } from "@/utils/fetchTitle";
interface TaskItem {
title: string;
description: string;
materialURL: string;
lastLearningStatus: string;
priority: number | string;
taskNum: string;
taskId: number;
reportCount: number;
fragmentCount: number;
effectiveTime: number;
statsLoaded: boolean;
sessionCount: number;
todayEffectiveTime: number;
weekEffectiveTime: number;
avgEffectiveTime: number;
avgEffectivenessRatio: number;
}
const tasks = ref<TaskItem[]>([]);
const selectedTaskId = ref<number | null>(null);
const loading = ref(false);
const taskApplications = ref<TaskApplication[]>([]);
const applicationsLoading = ref(false);
const applicationStatusOptions: { label: string; value: TaskApplication["status"] }[] = [
{ label: "待应用", value: "TODO" },
{ label: "进行中", value: "DOING" },
{ label: "已完成", value: "DONE" },
];
const selectedTask = computed(() =>
tasks.value.find((item) => item.taskId === selectedTaskId.value) || null
);
// 多 URL 支持
const urlList = computed(() => {
const raw = selectedTask.value?.materialURL || "";
return raw.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.startsWith("http"));
});
const urlTitles = ref<string[]>([]);
async function loadUrlTitles() {
const urls = urlList.value;
if (urls.length === 0) { urlTitles.value = []; return; }
urlTitles.value = await fetchTitles(urls);
}
const formatEffectiveTime = (seconds: number): string => {
if (!seconds || seconds <= 0) return "0分钟";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}小时${minutes}分钟`;
return `${minutes}分钟`;
};
async function loadTaskStats(taskNum: string) {
const task = tasks.value.find(t => t.taskNum === taskNum);
if (!task || task.statsLoaded) return;
try {
const res = await getReviewTaskStatsByTask(taskNum);
if (res?.data) {
task.reportCount = res.data.reportCount ?? 0;
task.fragmentCount = res.data.fragmentCount ?? 0;
task.effectiveTime = res.data.effectiveTime ?? 0;
task.sessionCount = res.data.sessionCount ?? 0;
task.todayEffectiveTime = res.data.todayEffectiveTime ?? 0;
task.weekEffectiveTime = res.data.weekEffectiveTime ?? 0;
task.avgEffectiveTime = res.data.avgEffectiveTime ?? 0;
task.avgEffectivenessRatio = res.data.avgEffectivenessRatio ?? 0;
task.statsLoaded = true;
}
} catch {
// 静默处理,不影响主流程
}
}
async function loadTaskApplications(taskNum: string) {
applicationsLoading.value = true;
try {
const res = await getTaskApplications(taskNum);
taskApplications.value = res?.data || [];
} catch (error: any) {
taskApplications.value = [];
ElMessage.error(error?.message || "应用场景加载失败");
} finally {
applicationsLoading.value = false;
}
}
const changeApplicationStatus = async (item: TaskApplication) => {
try {
await updateTaskApplication(item.id, {
title: item.title,
description: item.description,
resourceUrl: item.resourceUrl,
status: item.status,
});
ElMessage.success("应用场景状态已更新");
} catch (error: any) {
ElMessage.error(error?.message || "状态更新失败");
if (selectedTask.value) {
await loadTaskApplications(selectedTask.value.taskNum);
}
}
};
const fetchTasks = async () => {
loading.value = true;
try {
const previousSelectedTaskId = selectedTaskId.value;
const res = await request.get("/tasks", { pageSize: 100, pageNum: 1 });
tasks.value = (res?.data?.records || []).map((record: any) => ({
title: record.taskName,
description: record.taskDescription || "",
materialURL: record.materialURL || record.materialUrl || "",
lastLearningStatus: record.lastLearningStatus || "未开始",
priority: Number.isFinite(record.taskPriority) ? Math.round(record.taskPriority) : "-",
taskNum: record.taskNum,
taskId: record.id,
reportCount: 0,
fragmentCount: 0,
effectiveTime: 0,
statsLoaded: false,
sessionCount: 0,
todayEffectiveTime: 0,
weekEffectiveTime: 0,
avgEffectiveTime: 0,
avgEffectivenessRatio: 0,
}));
const targetTask = tasks.value.find(item => item.taskId === previousSelectedTaskId) || tasks.value[0];
const nextSelectedTaskId = targetTask?.taskId ?? null;
const shouldLoadStats = nextSelectedTaskId === selectedTaskId.value;
selectedTaskId.value = nextSelectedTaskId;
if (targetTask && shouldLoadStats) {
await loadTaskStats(targetTask.taskNum);
await loadTaskApplications(targetTask.taskNum);
}
} catch (error: any) {
tasks.value = [];
selectedTaskId.value = null;
taskApplications.value = [];
ElMessage.error(error?.message || "任务列表加载失败,请重试");
} finally {
loading.value = false;
}
};
watch(selectedTaskId, (newId) => {
const task = tasks.value.find(t => t.taskId === newId);
if (task) {
loadTaskStats(task.taskNum);
loadTaskApplications(task.taskNum);
loadUrlTitles();
} else {
taskApplications.value = [];
urlTitles.value = [];
}
});
const startTask = async () => {
if (!selectedTask.value) {
ElMessage.warning("请先选择一个任务");
return;
}
const taskNum = selectedTask.value.taskNum;
try {
const res = await getActiveSession(taskNum);
if (res?.code === 200 && res.data) {
// 有其他任务的活跃会话
const active = res.data;
ElMessageBox.confirm(
`你还有一个未完成的学习任务「${active.taskName}」(${active.sessionNum}),是否前往继续?`,
"有其他任务正在进行",
{ confirmButtonText: "前往继续", cancelButtonText: "取消", type: "warning" }
).then(() => {
router.push(`/start-task/${encodeURIComponent(active.taskNum)}`);
}).catch(() => {});
return;
}
} catch {
// 查询失败不阻塞,直接跳转
}
router.push(`/start-task/${encodeURIComponent(taskNum)}`);
};
const addTask = () => {
router.push({ name: "add-task" });
};
const changeTask = () => {
if (!selectedTask.value) {
ElMessage.warning("请先选择一个任务");
return;
}
router.push({ name: "update-task", params: { taskId: selectedTask.value.taskId } });
};
const removeTask = async () => {
if (!selectedTask.value) {
ElMessage.warning("请先选择一个任务");
return;
}
const target = selectedTask.value;
try {
await ElMessageBox.confirm(`确定删除任务「${target.title}」?关联的学习会话、学习报告、应用场景等数据将被一并删除。`, "确认删除", {
confirmButtonText: "确定删除",
cancelButtonText: "取消",
type: "warning",
});
} catch {
return; // 用户取消
}
const res = await request.del(`/tasks/${target.taskId}`, {});
if (res.code === 200) {
ElMessage.success(`任务 ${target.title} 删除成功`);
tasks.value = tasks.value.filter((item) => item.taskId !== target.taskId);
selectedTaskId.value = tasks.value[0]?.taskId ?? null;
}
};
// ============ 优先级权重配置 ============
const weightsDialogVisible = ref(false);
const savingWeights = ref(false);
const weightItems = ref([
{ key: "urgencyWeight", label: "紧急性", value: 35 },
{ key: "importanceWeight", label: "重要性", value: 25 },
{ key: "contentDifficultyWeight", label: "内容难度", value: 20 },
{ key: "futureValueWeight", label: "未来价值", value: 10 },
{ key: "subjectivePriorityWeight", label: "主观优先级", value: 10 },
]);
const weightsSum = computed(() =>
weightItems.value.reduce((acc, item) => acc + item.value, 0)
);
const openWeightsDialog = async () => {
try {
const res = await getPriorityWeights();
const data = res?.data;
if (data) {
for (const item of weightItems.value) {
const v = data[item.key];
if (typeof v === "number") item.value = Math.round(v * 100);
}
}
} catch {
// 读取失败时保持默认值
}
weightsDialogVisible.value = true;
};
const saveWeights = async () => {
if (weightsSum.value !== 100) {
ElMessage.warning(`五项权重之和需为 100%,当前 ${weightsSum.value}%`);
return;
}
savingWeights.value = true;
try {
const payload = Object.fromEntries(
weightItems.value.map((item) => [item.key, item.value / 100])
) as unknown as PriorityWeights;
const res = await savePriorityWeights(payload);
if (res?.code === 200) {
weightsDialogVisible.value = false;
ElMessage.success("权重已保存,任务优先级已重算");
await fetchTasks();
} else {
ElMessage.error(res?.message || "保存失败");
}
} catch (e: any) {
ElMessage.error(e.message || "请求失败");
} finally {
savingWeights.value = false;
}
};
onMounted(() => {
fetchTasks();
});
</script>
<template>
<div>
<section class="study-page">
<div class="layout-grid">
<aside class="surface-card task-list">
<div class="block-title">任务清单</div>
<el-scrollbar height="420px">
<div
v-for="item in tasks"
:key="item.taskId"
class="task-list-item"
:class="{ active: selectedTaskId === item.taskId }"
@click="selectedTaskId = item.taskId"
>
<p class="task-name">
<span class="task-priority">{{ item.priority }}</span>
{{ item.title }}
</p>
<p class="task-meta">状态{{ item.lastLearningStatus }}</p>
</div>
</el-scrollbar>
</aside>
<main class="content-zone">
<article class="surface-card detail-card" v-loading="loading">
<template v-if="selectedTask">
<div class="detail-head">
<h3>{{ selectedTask.title }}</h3>
<el-tag type="success" effect="plain">优先级 {{ selectedTask.priority }}</el-tag>
</div>
<div class="detail-block">
<p class="label">任务描述</p>
<el-input type="textarea" :model-value="selectedTask.description" :rows="4" readonly />
</div>
<div class="detail-block">
<p class="label">学习材料</p>
<div v-if="urlList.length > 0" class="material-list">
<div v-for="(url, i) in urlList" :key="i" class="material-item">
<el-link :href="url" target="_blank" type="success">
{{ urlTitles[i] || url }}
</el-link>
</div>
</div>
<p v-else class="empty-text">暂未填写材料地址</p>
</div>
<div class="detail-block" v-if="selectedTask.statsLoaded">
<p class="label">学习统计</p>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">学习次数</span>
<span class="stat-value">{{ selectedTask.sessionCount }} </span>
</div>
<div class="stat-item">
<span class="stat-label">今日有效学习</span>
<span class="stat-value">{{ formatEffectiveTime(selectedTask.todayEffectiveTime) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">本周有效学习</span>
<span class="stat-value">{{ formatEffectiveTime(selectedTask.weekEffectiveTime) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">累计有效学习</span>
<span class="stat-value">{{ formatEffectiveTime(selectedTask.effectiveTime) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">平均学习时长</span>
<span class="stat-value">{{ formatEffectiveTime(selectedTask.avgEffectiveTime) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">平均有效时间比</span>
<span class="stat-value">{{ (selectedTask.avgEffectivenessRatio * 100).toFixed(1) }}%</span>
</div>
<div class="stat-item">
<span class="stat-label">学习报告</span>
<span class="stat-value">{{ selectedTask.reportCount }} </span>
</div>
<div class="stat-item">
<span class="stat-label">学习残片</span>
<span class="stat-value">{{ selectedTask.fragmentCount }} </span>
</div>
</div>
</div>
<div class="detail-block" v-loading="applicationsLoading">
<p class="label">应用场景</p>
<div v-if="taskApplications.length > 0" class="application-list">
<div
v-for="item in taskApplications"
:key="item.id"
class="application-item"
>
<div class="application-main">
<strong>{{ item.title }}</strong>
<el-select
v-model="item.status"
size="small"
class="application-status"
@change="changeApplicationStatus(item)"
>
<el-option
v-for="option in applicationStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
<p v-if="item.description">{{ item.description }}</p>
<el-link v-if="item.resourceUrl" :href="item.resourceUrl" target="_blank" type="primary">
{{ item.resourceUrl }}
</el-link>
</div>
</div>
<p v-else class="empty-text">暂未设置应用场景</p>
</div>
</template>
<el-empty v-else description="暂无任务,请先创建任务" />
</article>
<aside class="surface-card action-card">
<div class="block-title">常用操作</div>
<el-button size="large" @click="fetchTasks" :loading="loading">刷新列表</el-button>
<el-button type="success" size="large" @click="startTask">开始任务</el-button>
<el-button size="large" @click="addTask">添加任务</el-button>
<el-button size="large" @click="changeTask">更新任务</el-button>
<el-button size="large" @click="openWeightsDialog">权重配置</el-button>
<el-button type="danger" size="large" @click="removeTask">删除任务</el-button>
</aside>
</main>
</div>
</section>
<el-dialog v-model="weightsDialogVisible" title="优先级权重配置" width="560px">
<p class="weights-hint">
调整五个维度在优先级计算中的占比保存后全部任务将按新权重重新排序
</p>
<div v-for="item in weightItems" :key="item.key" class="weight-row">
<span class="weight-label">{{ item.label }}</span>
<el-slider v-model="item.value" :min="0" :max="100" :step="5" class="weight-slider" />
<span class="weight-value">{{ item.value }}%</span>
</div>
<div class="weights-sum" :class="{ 'sum-error': weightsSum !== 100 }">
合计{{ weightsSum }}%需为 100%
</div>
<template #footer>
<el-button @click="weightsDialogVisible = false">取消</el-button>
<el-button type="success" :loading="savingWeights" :disabled="weightsSum !== 100" @click="saveWeights">
保存并重算
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.study-page {
display: flex;
flex-direction: column;
gap: 14px;
}
.weights-hint {
margin: 0 0 14px;
font-size: 13px;
color: var(--text-secondary);
}
.weight-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 6px;
}
.weight-label {
flex-shrink: 0;
width: 80px;
font-size: 14px;
color: var(--text-primary);
}
.weight-slider {
flex: 1;
}
.weight-value {
flex-shrink: 0;
width: 44px;
text-align: right;
font-size: 13px;
color: var(--green-800);
font-weight: 600;
}
.weights-sum {
margin-top: 8px;
text-align: right;
font-size: 13px;
color: var(--green-800);
font-weight: 600;
}
.weights-sum.sum-error {
color: #c62828;
}
.layout-grid {
display: grid;
grid-template-columns: 280px 1fr;
gap: 14px;
}
.task-list {
padding: 16px;
}
.block-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 700;
color: var(--green-900);
}
.task-list-item {
border: 1px solid var(--border-soft);
border-radius: 12px;
padding: 12px;
cursor: pointer;
background: #fbfefc;
transition: all 0.2s ease;
}
.task-list-item + .task-list-item {
margin-top: 10px;
}
.task-list-item:hover {
border-color: #b9d9c8;
}
.task-list-item.active {
border-color: var(--green-600);
background: var(--green-100);
box-shadow: inset 0 0 0 1px rgba(47, 143, 104, 0.2);
}
.task-name {
margin: 0;
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.task-priority {
flex-shrink: 0;
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--green-600);
color: #fff;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.task-meta {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-secondary);
}
.content-zone {
display: grid;
grid-template-columns: 1fr 220px;
gap: 14px;
}
.detail-card {
padding: 18px 20px;
}
.detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.detail-head h3 {
margin: 0;
font-size: 22px;
}
.detail-block {
margin-top: 16px;
}
.label {
margin: 0 0 8px;
color: var(--text-secondary);
font-size: 13px;
}
.empty-text {
margin: 0;
color: var(--text-secondary);
}
.material-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.material-item {
padding: 4px 0;
word-break: break-all;
}
.material-item .el-link {
font-size: 13px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
}
.stat-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.application-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.application-item {
padding: 12px 0;
border-bottom: 1px solid var(--border-soft);
}
.application-item:first-child {
padding-top: 0;
}
.application-item:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.application-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.application-main strong {
min-width: 0;
color: var(--text-primary);
word-break: break-word;
}
.application-status {
width: 112px;
flex: 0 0 auto;
}
.application-item p {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 13px;
word-break: break-word;
}
.action-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.action-card .el-button {
margin: 0;
}
@media (max-width: 1100px) {
.layout-grid {
grid-template-columns: 1fr;
}
.content-zone {
grid-template-columns: 1fr;
}
.action-card {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
.action-card .block-title {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.page-head {
flex-direction: column;
}
.action-card {
grid-template-columns: 1fr;
}
.application-main {
align-items: flex-start;
flex-direction: column;
}
.application-status {
width: 100%;
}
}
</style>