Files
lpt-fe/src/components/Study.vue
T

786 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, watch } from "vue";
import request from "@/utils/request";
import router from "@/router";
import { ElMessage, ElMessageBox } from "element-plus";
import { getReviewTaskStatsByTask } from "@/api/review";
import { renderMarkdown } from "@/utils/markdown";
import { applicationStatusOptions } from "@/utils/taskApplication";
import {
getPriorityWeights,
getTaskApplications,
savePriorityWeights,
updateTaskApplication,
type PriorityWeights,
type TaskApplication,
} from "@/api/tasks";
import { getUrlTitle } from "@/utils/fetchTitle";
interface TaskItem {
title: string;
description: string;
materialUrl: 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 currentPage = ref(1);
const totalTasks = ref(0);
const pageSize = 20;
const taskApplications = ref<TaskApplication[]>([]);
const applicationsLoading = ref(false);
const appUrlTitles = ref<Record<number, string>>({});
const updatingApplicationId = ref<number | null>(null);
const removingTask = ref(false);
const weightsLoading = ref(false);
const selectedTask = computed(() =>
tasks.value.find((item) => item.taskId === selectedTaskId.value) || null
);
const materialHtml = computed(() => renderMarkdown(selectedTask.value?.materialUrl || ""));
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 || [];
const map: Record<number, string> = {};
await Promise.all(
taskApplications.value
.filter((a) => a.resourceUrl)
.map(async (a) => {
map[a.id] = await getUrlTitle(a.resourceUrl!);
})
);
appUrlTitles.value = map;
} catch (error: any) {
taskApplications.value = [];
ElMessage.error(error?.message || "应用场景加载失败");
} finally {
applicationsLoading.value = false;
}
}
const changeApplicationStatus = async (item: TaskApplication) => {
if (updatingApplicationId.value !== null) return;
updatingApplicationId.value = item.id;
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);
}
} finally {
updatingApplicationId.value = null;
}
};
const fetchTasks = async (page = currentPage.value) => {
loading.value = true;
try {
const previousSelectedTaskId = selectedTaskId.value;
currentPage.value = page;
const res = await request.get("/tasks", { pageSize, pageNum: page });
totalTasks.value = res?.data?.total || 0;
tasks.value = (res?.data?.records || []).map((record: any) => ({
title: record.taskName,
description: record.taskDescription || "",
materialUrl: record.materialUrl || "",
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);
} else {
taskApplications.value = [];
}
});
const handlePageChange = (page: number) => {
selectedTaskId.value = null;
taskApplications.value = [];
fetchTasks(page);
};
const startTask = () => {
if (!selectedTask.value) {
ElMessage.warning("请先选择一个任务");
return;
}
router.push(`/start-task/${encodeURIComponent(selectedTask.value.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;
}
if (removingTask.value) return;
removingTask.value = true;
const target = selectedTask.value;
try {
await ElMessageBox.confirm(`确定删除任务「${target.title}」?关联的学习会话、学习报告、应用场景等数据将被一并删除。`, "确认删除", {
confirmButtonText: "确定删除",
cancelButtonText: "取消",
type: "warning",
});
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;
}
} catch {
// 用户取消或删除失败,保持页面状态
} finally {
removingTask.value = false;
}
};
// ============ 优先级权重配置 ============
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 () => {
if (weightsLoading.value) return;
weightsLoading.value = true;
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 {
// 读取失败时保持默认值
} finally {
weightsLoading.value = false;
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>
</div>
</el-scrollbar>
<el-pagination
v-if="totalTasks > pageSize"
small
layout="prev, pager, next"
:total="totalTasks"
:page-size="pageSize"
:current-page="currentPage"
@current-change="handlePageChange"
class="task-pagination"
/>
</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>
<p class="description-text">{{ selectedTask.description || '暂无描述' }}</p>
</div>
<div class="detail-block">
<p class="label">学习材料</p>
<div v-if="materialHtml" class="material-content" v-html="materialHtml" />
<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"
:loading="updatingApplicationId === item.id"
:disabled="updatingApplicationId !== null"
@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">
{{ appUrlTitles[item.id] || 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 @click="fetchTasks" :loading="loading">刷新列表</el-button>
<el-button type="success" size="large" @click="startTask">开始任务</el-button>
<el-button @click="addTask">添加任务</el-button>
<el-button @click="changeTask">更新任务</el-button>
<el-button :loading="weightsLoading" @click="openWeightsDialog">权重配置</el-button>
<el-button type="danger" :loading="removingTask" @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-pagination {
margin-top: 10px;
justify-content: center;
}
.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;
}
.description-text {
margin: 0;
font-size: 14px;
line-height: 1.7;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.detail-block {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border-soft);
}
.detail-block:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.label {
margin: 0 0 10px;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.5px;
}
.empty-text {
margin: 0;
color: var(--text-secondary);
}
.material-content {
font-size: 14px;
line-height: 1.8;
word-break: break-word;
color: var(--text-primary);
}
.material-content :deep(a) {
color: var(--green-600);
text-decoration: none;
border-bottom: 1px dashed var(--green-400);
transition: color 0.15s, border-color 0.15s;
}
.material-content :deep(a:hover) {
color: var(--green-800);
border-bottom-style: solid;
}
.material-content :deep(a)::after {
content: " ↗";
font-size: 11px;
opacity: 0.5;
}
.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: 13px;
color: var(--text-secondary);
}
.stat-value {
font-size: 15px;
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) {
.weight-row {
gap: 8px;
}
.weight-label {
width: 64px;
font-size: 13px;
}
.action-card {
grid-template-columns: 1fr;
}
.application-main {
align-items: flex-start;
flex-direction: column;
}
.application-status {
width: 100%;
}
}
</style>