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

1240 lines
34 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, onUnmounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import request from "@/utils/request";
import { useTimer } from "@/components/composables/useTimer";
import {
continueSession,
endSession,
getSessionDetail,
pauseSession,
startOrContinueStudySession,
} from "@/api/studySessions";
import { useStudyFragment } from "@/components/composables/fragment";
import { getFragmentsBySession, updateFragments } from "@/api/reportFragments";
import { getExpectation, getReportDraft, getTaskFragments, getTaskReports, upsertExpectation } from "@/api/studySessions";
import router from "@/router";
import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
import { renderMarkdown } from "@/utils/markdown";
const {
fragmentsDialogVisible,
fragmentContent,
openFragmentDialog,
closeFragmentDialog,
confirmGenerateFragment,
} = useStudyFragment();
const summaryDialogVisible = ref(false);
const summaryContent = ref("");
const summaryPreview = ref(false);
const summaryLoading = ref(false);
const summaryLoadingSeconds = ref(0);
let summaryLoadingTimer: ReturnType<typeof setInterval> | null = null;
// 学习预期
const expectationDialogVisible = ref(false);
const expectationContent = ref("");
const expectationSaved = ref("");
const savingExpectation = ref(false);
// 当前会话碎片列表
interface FragmentItem {
id: number;
content: string;
}
const fragmentsList = ref<FragmentItem[]>([]);
const editingFragmentId = ref<number | null>(null);
const editFragmentContent = ref("");
const savingFragment = ref(false);
// 历史学习记录
const showHistory = ref(false);
const historyTab = ref("fragments");
const historyKeyword = ref("");
let historyDebounce: ReturnType<typeof setTimeout> | null = null;
const loadingHistory = ref(false);
const historyFragments = ref<any[]>([]);
const fragmentsTotal = ref(0);
const fragmentsPage = ref(1);
const historyReports = ref<any[]>([]);
const reportsTotal = ref(0);
const reportsPage = ref(1);
const PAGE_SIZE = 10;
watch(showHistory, (val) => {
if (val) {
// 展开时立即加载当前 tab 的数据
if (historyTab.value === "fragments") loadHistoryFragments();
else loadHistoryReports();
}
});
const loadHistoryFragments = async () => {
loadingHistory.value = true;
try {
const res = await getTaskFragments(taskNum, fragmentsPage.value, PAGE_SIZE, historyKeyword.value || undefined);
const data = res?.data;
historyFragments.value = data?.records || [];
fragmentsTotal.value = data?.total || 0;
} catch {
historyFragments.value = [];
fragmentsTotal.value = 0;
} finally {
loadingHistory.value = false;
}
};
const loadHistoryReports = async () => {
loadingHistory.value = true;
try {
const res = await getTaskReports(taskNum, reportsPage.value, PAGE_SIZE, historyKeyword.value || undefined);
const data = res?.data;
historyReports.value = data?.records || [];
reportsTotal.value = data?.total || 0;
} catch {
historyReports.value = [];
reportsTotal.value = 0;
} finally {
loadingHistory.value = false;
}
};
const searchHistory = () => {
if (historyDebounce) clearTimeout(historyDebounce);
historyDebounce = setTimeout(() => {
fragmentsPage.value = 1;
reportsPage.value = 1;
if (historyTab.value === "fragments") loadHistoryFragments();
else loadHistoryReports();
}, 300);
};
const onHistoryTabChange = () => {
fragmentsPage.value = 1;
reportsPage.value = 1;
if (historyTab.value === "fragments") loadHistoryFragments();
else loadHistoryReports();
};
const onFragmentsPageChange = (p: number) => {
fragmentsPage.value = p;
loadHistoryFragments();
};
const onReportsPageChange = (p: number) => {
reportsPage.value = p;
loadHistoryReports();
};
const route = useRoute();
const taskNum = route.params.taskNum as string;
const taskInfo = ref({
sessionNum: "",
sessionState: "--",
taskName: "",
taskNum,
taskId: 0,
materialUrl: "",
startTime: "",
endTime: "",
lastStartTime: "",
actualTime: 0,
effectiveTime: 0,
effectivenessRatio: "--",
pointerPosition: 0,
systemMessage: "",
});
// 学习材料展示/编辑
const editingMaterial = ref(false);
const editMaterialContent = ref("");
const savingMaterial = ref(false);
const materialHtml = computed(() => renderMarkdown(taskInfo.value.materialUrl || ""));
function startEditMaterial() {
editMaterialContent.value = taskInfo.value.materialUrl;
editingMaterial.value = true;
}
async function saveMaterial() {
savingMaterial.value = true;
try {
// 先拉取完整任务数据,只改 materialUrl,其他字段保持不变
const res = await request.get(`/tasks/${taskInfo.value.taskId}`);
const current = res?.data || {};
await request.put(`/tasks/${taskInfo.value.taskId}`, {
id: taskInfo.value.taskId,
taskName: current.taskName,
taskDescription: current.taskDescription || "",
materialUrl: editMaterialContent.value,
urgency: current.urgency ?? 0,
importance: current.importance ?? 0,
contentDifficulty: current.contentDifficulty ?? 0,
futureValue: current.futureValue ?? 0,
subjectivePriority: current.subjectivePriority ?? 0,
});
taskInfo.value.materialUrl = editMaterialContent.value;
editingMaterial.value = false;
ElMessage.success("学习材料已更新");
} catch (e: any) {
ElMessage.error(e?.message || "更新失败");
} finally {
savingMaterial.value = false;
}
}
const {
timerMinutes,
timerSeconds,
runCountdown,
syncDisplay,
clear,
timerRunning,
timerIsOver,
checkAudioPermission,
requestAudioPermission,
} = useTimer();
// 休息状态持久化(刷新后恢复)
const BREAK_END_KEY = computed(() => `breakEnd_${taskNum}`);
const isBreak = ref(false);
const breakAudio = new Audio('/resource/notification.mp3');
// 恢复提示
const showResumeHint = ref(route.query.resume === "true");
const progress = computed(() => {
const total = isBreak.value ? 5 * 60 : 25 * 60;
const remaining = timerMinutes.value * 60 + timerSeconds.value;
return (remaining / total) * 100;
});
const nowTimestamp = ref(Date.now());
let displayInterval: number | undefined;
const formatDuration = (value: number | string) => {
const totalSeconds = Math.max(0, Math.floor(Number(value) || 0));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}小时 ${minutes}${seconds.toString().padStart(2, "0")}秒`;
}
if (minutes > 0) {
return `${minutes}${seconds.toString().padStart(2, "0")}秒`;
}
return `${seconds}秒`;
};
const toDate = (value: unknown): Date | null => {
if (value == null || value === "") return null;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
if (typeof value === "number") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
if (Array.isArray(value)) {
const [year, month, day, hour = 0, minute = 0, second = 0] = value.map(Number);
if (!year || !month || !day) return null;
const date = new Date(year, month - 1, day, hour, minute, second);
return Number.isNaN(date.getTime()) ? null : date;
}
if (typeof value === "string") {
// 去掉末尾的 'Z':后端 LocalDateTime 无时区,序列化时不应带 Z
// (旧版序列化器曾错误添加字面量 Z,此处做兼容处理)
const clean = value.endsWith("Z") ? value.slice(0, -1) : value;
const normalized = clean.includes("T") ? clean : clean.replace(" ", "T");
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
return null;
};
const toSeconds = (start: Date, endTimestamp: number) => {
return Math.max(0, Math.floor((endTimestamp - start.getTime()) / 1000));
};
const actualTimeSeconds = computed(() => {
const fallback = Math.max(0, Math.floor(Number(taskInfo.value.actualTime) || 0));
const startDate = toDate(taskInfo.value.startTime);
if (!startDate) return fallback;
// 实际用时:开始后持续计时,暂停时也继续累计,结束后冻结。
if (taskInfo.value.sessionState === "ENDED") {
const endDate = toDate(taskInfo.value.endTime);
if (!endDate) return fallback;
return toSeconds(startDate, endDate.getTime());
}
return toSeconds(startDate, nowTimestamp.value);
});
const effectiveTimeSeconds = computed(() => {
const base = Math.max(0, Math.floor(Number(taskInfo.value.effectiveTime) || 0));
if (taskInfo.value.sessionState !== "ONGOING") return base;
const lastStartDate = toDate(taskInfo.value.lastStartTime);
if (!lastStartDate) return base;
return base + toSeconds(lastStartDate, nowTimestamp.value);
});
const actualTimeText = computed(() => formatDuration(actualTimeSeconds.value));
const effectiveTimeText = computed(() => formatDuration(effectiveTimeSeconds.value));
const statusText = computed(() => {
switch (taskInfo.value.sessionState) {
case "ONGOING":
return "进行中";
case "PAUSED":
return "已暂停";
case "ENDED":
return "已结束";
default:
return "未开始";
}
});
const startTimer = async () => {
if (taskInfo.value.sessionState === "PAUSED") {
const res = await continueSession(taskInfo.value.sessionNum);
if (res.code === 200) ElMessage.success("任务继续");
await loadTaskSession();
}
// 手动开始 → 清除休息状态
localStorage.removeItem(BREAK_END_KEY.value);
isBreak.value = false;
const duration = taskInfo.value.pointerPosition || 25 * 60 * 1000;
runCountdown(duration);
};
const stopTimer = async () => {
const res = await pauseSession(taskInfo.value.sessionNum);
if (res.code === 200) {
ElMessage.success("任务暂停");
// 重新获取会话数据,同步后端计算后的时间字段
try {
const detail = await getSessionDetail(taskInfo.value.sessionNum);
if (detail?.data) {
const d = detail.data;
taskInfo.value.sessionState = d.sessionState ?? "PAUSED";
taskInfo.value.actualTime = d.actualTime ?? 0;
taskInfo.value.effectiveTime = d.effectiveTime ?? 0;
taskInfo.value.effectivenessRatio = d.effectivenessRatio ?? 0;
taskInfo.value.startTime = d.startTime ?? taskInfo.value.startTime;
taskInfo.value.endTime = d.endTime ?? "";
taskInfo.value.lastStartTime = d.lastStartTime ?? "";
taskInfo.value.pointerPosition = d.pointerPosition ?? 0;
}
} catch {
taskInfo.value.sessionState = "PAUSED";
}
}
clear();
clearBreakState();
};
const clearBreakState = () => {
localStorage.removeItem(BREAK_END_KEY.value);
isBreak.value = false;
};
const endTimer = async (content: string) => {
if (!content) {
ElMessage.warning("请输入学习总结内容");
return;
}
try {
await ElMessageBox.confirm("确定要结束本次学习会话吗?", "结束确认", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
} catch {
// 用户取消结束会话,不做任何操作
return;
}
try {
const res = await endSession(taskInfo.value.sessionNum, content);
if (res.code === 200) {
if (res.message && res.message !== "请求成功") {
ElMessage.warning(res.message);
} else {
ElMessage.success("任务结束");
}
} else {
ElMessage.error(res.message || "结束会话失败");
}
clear();
clearBreakState();
localStorage.removeItem("activeSession");
await router.push("/study");
} catch {
ElMessage.error("结束会话失败,请重试");
}
};
const openSummaryDialog = async () => {
summaryDialogVisible.value = true;
// 有残片且未填写过总结时,用 AI 聚合草稿作为编辑起点
if (!summaryContent.value.trim() && fragmentsList.value.length > 0) {
summaryLoading.value = true;
summaryLoadingSeconds.value = 0;
summaryLoadingTimer = setInterval(() => { summaryLoadingSeconds.value++; }, 1000);
try {
const res = await getReportDraft(taskInfo.value.sessionNum);
if (res?.code === 200 && res.data) {
summaryContent.value = res.data;
}
} catch {
// 草稿失败不阻塞手写
} finally {
summaryLoading.value = false;
if (summaryLoadingTimer) { clearInterval(summaryLoadingTimer); summaryLoadingTimer = null; }
}
}
};
const closeSummaryDialog = () => {
summaryDialogVisible.value = false;
if (summaryLoadingTimer) { clearInterval(summaryLoadingTimer); summaryLoadingTimer = null; }
};
const restTimer = async () => {
await stopTimer();
const breakMs = 5 * 60 * 1000;
localStorage.setItem(BREAK_END_KEY.value, String(Date.now() + breakMs));
isBreak.value = true;
runCountdown(breakMs, () => {
localStorage.removeItem(BREAK_END_KEY.value);
isBreak.value = false;
breakAudio.loop = true;
breakAudio.play();
ElMessageBox.alert('休息结束!点击"开始"继续学习', '休息结束', {
confirmButtonText: '关闭铃声',
callback: () => {
breakAudio.pause();
breakAudio.currentTime = 0;
ElMessage.success('铃声已关闭');
},
});
});
};
const loadTaskSession = async () => {
try {
const res = await startOrContinueStudySession(taskNum);
Object.assign(taskInfo.value, res.data);
await loadExpectation();
// 检查是否有未结束的休息
const storedEnd = localStorage.getItem(BREAK_END_KEY.value);
if (storedEnd) {
const remaining = parseInt(storedEnd, 10) - Date.now();
if (remaining > 0) {
isBreak.value = true;
runCountdown(remaining, () => {
localStorage.removeItem(BREAK_END_KEY.value);
isBreak.value = false;
breakAudio.loop = true;
breakAudio.play();
ElMessageBox.alert('休息结束!点击"开始"继续学习', '休息结束', {
confirmButtonText: '关闭铃声',
callback: () => {
breakAudio.pause();
breakAudio.currentTime = 0;
ElMessage.success('铃声已关闭');
},
});
});
loadFragments();
return;
} else {
localStorage.removeItem(BREAK_END_KEY.value);
}
}
// 持久化活跃会话(用于跨页面恢复 + 阻止多任务)
localStorage.setItem("activeSession", JSON.stringify({
taskNum: taskInfo.value.taskNum,
sessionNum: taskInfo.value.sessionNum,
taskName: taskInfo.value.taskName,
}));
if (taskInfo.value.sessionState === "ONGOING") {
startTimer();
} else {
syncDisplay(taskInfo.value.pointerPosition || 25 * 60 * 1000);
}
loadFragments();
} catch (err: any) {
ElMessage.error(err.message || "加载任务失败");
}
};
// 学习预期:会话必须有预期才能开始计时
const loadExpectation = async () => {
if (!taskInfo.value.sessionNum) return;
try {
const res = await getExpectation(taskInfo.value.sessionNum);
expectationSaved.value = res?.data?.description || "";
} catch {
expectationSaved.value = "";
}
if (!expectationSaved.value) {
expectationDialogVisible.value = true;
}
};
const saveExpectation = async () => {
if (!expectationContent.value.trim()) {
ElMessage.warning("学习预期不可为空");
return;
}
savingExpectation.value = true;
try {
const res = await upsertExpectation(taskInfo.value.sessionNum, expectationContent.value);
if (res?.code === 200) {
expectationSaved.value = expectationContent.value;
expectationDialogVisible.value = false;
} else {
ElMessage.error(res?.message || "保存失败");
}
} catch (e: any) {
ElMessage.error(e.message || "请求失败");
} finally {
savingExpectation.value = false;
}
};
const initPage = async () => {
const hasPermission = await checkAudioPermission();
if (!hasPermission) {
const activated = await requestAudioPermission();
if (!activated) return;
}
await loadTaskSession();
};
// 碎片列表
const loadFragments = async () => {
if (!taskInfo.value.sessionNum) return;
try {
const res = await getFragmentsBySession(taskInfo.value.sessionNum);
if (res?.code === 200) {
fragmentsList.value = res.data || [];
}
} catch {
// 非关键数据
}
};
const startEditFragment = (fragment: FragmentItem) => {
editingFragmentId.value = fragment.id;
editFragmentContent.value = fragment.content;
};
const cancelEditFragment = () => {
editingFragmentId.value = null;
editFragmentContent.value = "";
};
const saveEditFragment = async (id: number) => {
if (!editFragmentContent.value.trim()) {
ElMessage.warning("内容不能为空");
return;
}
savingFragment.value = true;
try {
const res = await updateFragments(id, editFragmentContent.value);
if (res?.code === 200) {
const item = fragmentsList.value.find((f) => f.id === id);
if (item) item.content = editFragmentContent.value;
editingFragmentId.value = null;
editFragmentContent.value = "";
ElMessage.success("保存成功");
} else {
ElMessage.error(res?.message || "保存失败");
}
} catch (e: any) {
ElMessage.error(e.message || "请求失败");
} finally {
savingFragment.value = false;
}
};
onMounted(() => {
displayInterval = window.setInterval(() => {
nowTimestamp.value = Date.now();
}, 1000);
initPage();
});
// 碎片创建成功后刷新列表
watch(fragmentsDialogVisible, (newVal, oldVal) => {
if (oldVal === true && newVal === false) {
loadFragments();
}
});
onUnmounted(() => {
if (displayInterval) {
clearInterval(displayInterval);
}
clear();
});
</script>
<template>
<div>
<section class="start-page">
<el-alert
v-if="showResumeHint"
title="已恢复上次的学习会话,继续学习吧"
type="success"
show-icon
:closable="true"
@close="showResumeHint = false"
/>
<div class="task-info-bar">
<el-tag type="success" effect="plain">任务编号{{ taskInfo.taskNum }}</el-tag>
<span class="status-text">状态{{ statusText }}</span>
</div>
<!-- 学习材料 -->
<article class="surface-card material-session-card">
<div class="material-session-header">
<span class="material-session-label">学习材料</span>
<el-button
v-if="!editingMaterial"
text
size="small"
type="primary"
@click="startEditMaterial"
>编辑</el-button>
</div>
<div v-if="materialHtml" class="material-session-content" v-html="materialHtml" />
<p v-else class="empty-text">暂未填写材料地址</p>
<el-dialog v-model="editingMaterial" title="编辑学习材料" width="560px">
<el-input v-model="editMaterialContent" type="textarea" :rows="10" placeholder="支持 Markdown 格式" />
<p class="edit-hint">[链接文字](url) 或直接粘贴链接保存时自动获取标题</p>
<template #footer>
<el-button @click="editingMaterial = false">取消</el-button>
<el-button type="success" :loading="savingMaterial" @click="saveMaterial">保存</el-button>
</template>
</el-dialog>
</article>
<!-- 学习预期 -->
<article class="surface-card expectation-card" v-if="expectationSaved">
<span class="expectation-label">本次预期</span>
<span class="expectation-text">{{ expectationSaved }}</span>
<el-button
text
size="small"
type="primary"
@click="expectationContent = expectationSaved; expectationDialogVisible = true"
>
修改
</el-button>
</article>
<el-alert
v-if="taskInfo.systemMessage"
:title="taskInfo.systemMessage"
type="warning"
show-icon
:closable="false"
/>
<div class="session-grid">
<article class="surface-card timer-card">
<p class="timer-title">{{ isBreak ? '☕ 休息中' : '当前计时' }}</p>
<el-progress
type="dashboard"
:percentage="progress"
:stroke-width="14"
:width="250"
:color="isBreak ? '#e6a23c' : '#2f8f68'"
>
<div class="timer-number" :class="{ 'break-color': isBreak }">
{{ timerMinutes.toString().padStart(2, "0") }}:{{ timerSeconds.toString().padStart(2, "0") }}
</div>
</el-progress>
<p class="timer-tip">{{ isBreak ? '休息倒计时' : '番茄钟默认 25 分钟,休息计时 5 分钟' }}</p>
</article>
<article class="surface-card info-card">
<h3>会话数据</h3>
<div class="info-grid">
<div class="metric">
<span>实际用时</span>
<strong>{{ actualTimeText }}</strong>
</div>
<div class="metric">
<span>有效学习时长</span>
<strong>{{ effectiveTimeText }}</strong>
</div>
<div class="metric">
<span>有效时间比</span>
<strong>{{ taskInfo.effectivenessRatio }}</strong>
</div>
<div class="metric">
<span>会话状态</span>
<strong>{{ isBreak ? '休息中' : statusText }}</strong>
</div>
</div>
<div class="action-row">
<template v-if="isBreak">
<el-tag type="warning" effect="plain" size="large">休息中 · {{ timerMinutes }}:{{ timerSeconds.toString().padStart(2, "0") }}</el-tag>
<el-button @click="openFragmentDialog">生成残片</el-button>
</template>
<template v-else>
<el-button type="success" @click="startTimer" :disabled="timerRunning">开始</el-button>
<el-button @click="stopTimer" :disabled="!timerRunning" v-if="!timerIsOver">暂停</el-button>
<el-button @click="openFragmentDialog">生成残片</el-button>
<el-button v-if="timerIsOver" plain type="success" @click="restTimer">休息</el-button>
</template>
<el-button type="danger" plain @click="openSummaryDialog">结束会话</el-button>
</div>
</article>
</div>
<!-- 当前会话学习残片 -->
<article class="surface-card fragments-card" v-if="fragmentsList.length > 0">
<h3>当前会话学习残片</h3>
<div
v-for="(fragment, index) in fragmentsList"
:key="fragment.id"
class="fragment-item"
>
<span class="fragment-index">{{ index + 1 }}</span>
<template v-if="editingFragmentId === fragment.id">
<el-input
v-model="editFragmentContent"
type="textarea"
:rows="3"
placeholder="请输入学习内容"
/>
<div class="fragment-edit-actions">
<el-button size="small" @click="cancelEditFragment">取消</el-button>
<el-button size="small" type="success" :loading="savingFragment" @click="saveEditFragment(fragment.id)">保存</el-button>
</div>
</template>
<template v-else>
<span class="fragment-content">{{ fragment.content }}</span>
<el-button text type="primary" size="small" @click="startEditFragment(fragment)">编辑</el-button>
</template>
</div>
</article>
<!-- 历史学习记录 -->
<article class="surface-card history-card">
<div class="history-header" @click="showHistory = !showHistory">
<div class="history-header-left">
<span class="history-toggle">{{ showHistory ? "▾" : "▸" }}</span>
<h3>📚 历史学习记录</h3>
</div>
<el-tag size="small" type="info" effect="plain">展开后查看该任务全部残片与报告</el-tag>
</div>
<template v-if="showHistory">
<el-input v-model="historyKeyword" placeholder="搜索历史内容…" clearable class="history-search" @input="searchHistory" @clear="searchHistory" />
<el-tabs v-model="historyTab" @tab-change="onHistoryTabChange" class="history-tabs">
<el-tab-pane label="学习残片" name="fragments">
<div v-loading="loadingHistory" class="history-list">
<div v-for="item in historyFragments" :key="item.id" class="history-item">
<span class="session-tag">{{ item.sessionNum }}</span>
<MarkdownRenderer :content="item.content" />
</div>
<el-empty v-if="!loadingHistory && historyFragments.length === 0" description="暂未找到匹配的残片" :image-size="48"></el-empty>
</div>
<el-pagination v-if="fragmentsTotal > 10" small layout="prev, pager, next" :total="fragmentsTotal" :page-size="10" :current-page="fragmentsPage" @current-change="onFragmentsPageChange" background></el-pagination>
</el-tab-pane>
<el-tab-pane label="学习报告" name="reports">
<div v-loading="loadingHistory" class="history-list">
<div v-for="item in historyReports" :key="item.id" class="history-item report-item">
<div class="report-header">
<span class="session-tag">{{ item.sessionNum }}</span>
<span v-if="item.sessionExpectation" class="expectation-hint">{{ item.sessionExpectation }}</span>
</div>
<MarkdownRenderer :content="item.content" />
</div>
<el-empty v-if="!loadingHistory && historyReports.length === 0" description="暂未找到匹配的报告" :image-size="48" />
</div>
<el-pagination v-if="reportsTotal > 10" small layout="prev, pager, next" :total="reportsTotal" :page-size="10" :current-page="reportsPage" @current-change="onReportsPageChange" background />
</el-tab-pane>
</el-tabs>
</template>
</article>
</section>
<el-dialog v-model="fragmentsDialogVisible" title="生成学习残片" width="520px">
<el-input
type="textarea"
v-model="fragmentContent"
placeholder="请输入本次学习内容"
:rows="5"
/>
<template #footer>
<el-button @click="closeFragmentDialog">取消</el-button>
<el-button type="success" @click="confirmGenerateFragment(taskInfo.sessionNum)">确定生成</el-button>
</template>
</el-dialog>
<el-dialog
v-model="expectationDialogVisible"
title="学习预期"
width="520px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="!!expectationSaved"
>
<p class="dialog-hint">开始学习前写下这次想学会什么结束时会与实际学习内容对照</p>
<el-input
type="textarea"
v-model="expectationContent"
placeholder="例如:理解线程池的核心参数和拒绝策略"
:rows="4"
/>
<template #footer>
<el-button v-if="expectationSaved" @click="expectationDialogVisible = false">取消</el-button>
<el-button type="success" :loading="savingExpectation" @click="saveExpectation">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="summaryDialogVisible" title="结束会话总结" width="640px">
<div v-if="expectationSaved" class="summary-expectation">
<span class="expectation-label">本次预期</span>
<MarkdownRenderer :content="expectationSaved" />
</div>
<p v-if="summaryLoading" class="ai-waiting">
AI 正在聚合残片生成草稿已等待 {{ summaryLoadingSeconds }}
</p>
<div class="summary-editor">
<div class="summary-toolbar">
<el-button
size="small"
:type="summaryPreview ? '' : 'primary'"
@click="summaryPreview = false"
>编辑</el-button>
<el-button
size="small"
:type="summaryPreview ? 'primary' : ''"
@click="summaryPreview = true"
>预览</el-button>
</div>
<el-input
v-if="!summaryPreview"
v-loading="summaryLoading"
type="textarea"
v-model="summaryContent"
placeholder="总结本次学到的内容(已有残片时自动生成草稿,可修改)"
:rows="8"
/>
<div v-else class="summary-preview">
<MarkdownRenderer :content="summaryContent" />
</div>
</div>
<template #footer>
<el-button @click="closeSummaryDialog">取消</el-button>
<el-button type="success" @click="endTimer(summaryContent)">确认结束</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.start-page {
display: flex;
flex-direction: column;
gap: 14px;
}
.task-info-bar {
display: flex;
align-items: center;
gap: 12px;
}
.expectation-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
}
.expectation-label {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--green-700);
background: #e8f5e9;
padding: 2px 8px;
border-radius: 4px;
}
.expectation-text {
flex: 1;
font-size: 14px;
color: var(--text-primary);
word-break: break-word;
}
.material-session-card {
padding: 12px 16px;
}
.material-session-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.material-session-label {
font-size: 12px;
font-weight: 700;
color: var(--green-700);
background: #e8f5e9;
padding: 2px 8px;
border-radius: 4px;
}
.material-session-content {
font-size: 14px;
line-height: 1.7;
word-break: break-word;
color: var(--text-primary);
}
.material-session-content :deep(a) {
color: var(--green-600);
text-decoration: none;
border-bottom: 1px dashed var(--green-400);
}
.material-session-content :deep(a:hover) {
color: var(--green-800);
border-bottom-style: solid;
}
.edit-hint {
margin: 6px 0 0;
font-size: 12px;
color: var(--text-secondary);
}
.dialog-hint {
margin: 0 0 10px;
font-size: 13px;
color: var(--text-secondary);
}
.ai-waiting {
font-size: 13px;
color: #e6a23c;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 6px;
}
.ai-waiting::before {
content: "";
width: 14px;
height: 14px;
border: 2px solid #f3d19e;
border-top-color: #e6a23c;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.summary-expectation {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 10px;
font-size: 13px;
color: var(--text-secondary);
}
.summary-editor {
border: 1px solid #e0e8e0;
border-radius: 6px;
overflow: hidden;
}
.summary-toolbar {
display: flex;
gap: 0;
border-bottom: 1px solid #e0e8e0;
background: #f9fbf9;
padding: 4px;
}
.summary-toolbar .el-button {
border-radius: 4px;
border: none;
}
.summary-preview {
padding: 12px 16px;
min-height: 200px;
max-height: 360px;
overflow-y: auto;
background: #fff;
}
.status-text {
font-size: 14px;
color: var(--text-secondary);
}
.session-grid {
display: grid;
grid-template-columns: 380px 1fr;
gap: 14px;
}
.timer-card {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.timer-title {
margin: 0;
color: var(--green-900);
font-weight: 700;
}
.timer-number {
font-size: 28px;
letter-spacing: 1px;
color: var(--green-900);
font-weight: 700;
}
.timer-number.break-color {
color: #d48806;
}
.timer-tip {
margin: 14px 0 0;
color: var(--text-secondary);
font-size: 12px;
}
.info-card {
padding: 20px;
}
.info-card h3 {
margin: 0;
color: var(--green-900);
}
.info-grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.metric {
border: 1px solid var(--border-soft);
border-radius: 12px;
padding: 10px 12px;
background: #fbfefc;
}
.metric span {
display: block;
font-size: 12px;
color: var(--text-secondary);
}
.metric strong {
margin-top: 6px;
display: block;
font-size: 18px;
color: var(--green-900);
}
.action-row {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.fragments-card {
padding: 20px;
}
.fragments-card h3 {
margin: 0 0 14px;
color: var(--green-900);
}
.fragment-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border-soft);
}
.fragment-item:last-child {
border-bottom: none;
}
.fragment-index {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--green-600);
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.fragment-content {
flex: 1;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
word-break: break-word;
}
.fragment-edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
@media (max-width: 1100px) {
.session-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
}
}
/* ---- 历史学习记录 ---- */
.history-card {
padding: 20px;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
gap: 10px;
}
.history-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.history-header-left h3 {
margin: 0;
font-size: 15px;
color: var(--green-900);
}
.history-toggle {
flex-shrink: 0;
font-size: 14px;
color: var(--green-700);
width: 18px;
text-align: center;
}
.history-search {
margin-top: 12px;
}
.history-tabs {
margin-top: 8px;
}
.history-list {
min-height: 60px;
}
.history-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid var(--border-soft);
}
.history-item:last-child {
border-bottom: none;
}
.session-tag {
flex-shrink: 0;
font-size: 11px;
color: var(--green-700);
background: #e8f5e9;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
font-weight: 600;
}
.report-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.expectation-hint {
font-size: 12px;
color: var(--text-secondary, #666);
background: #f5f7f5;
padding: 1px 8px;
border-radius: 4px;
white-space: nowrap;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
</style>