Files
lpt-fe/src/components/StartTask.vue
T
cat-shark fce4ba194b fix: 完善任务导航与结束会话错误处理
- 结束会话 API 异常时显示错误,不导航(可重试)
- 结束会话硬跳转改为软导航 router.push
- TaskForm 创建/更新/取消后跳转任务列表页
- 移除 TaskForm 页面内大标题
2026-05-23 22:55:03 +08:00

452 lines
11 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 } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { useTimer } from "@/components/composables/useTimer";
import {
continueSession,
endSession,
pauseSession,
startOrContinueStudySession,
} from "@/api/studySessions";
import { useStudyFragment } from "@/components/composables/fragment";
import router from "@/router";
const {
fragmentsDialogVisible,
fragmentContent,
openFragmentDialog,
closeFragmentDialog,
confirmGenerateFragment,
} = useStudyFragment();
const summaryDialogVisible = ref(false);
const summaryContent = ref("");
const route = useRoute();
const taskNum = route.params.taskNum as string;
const taskInfo = ref({
sessionNum: "",
sessionState: "--",
taskName: "",
taskNum,
startTime: "",
endTime: "",
lastStartTime: "",
actualTime: 0,
effectiveTime: 0,
effectivenessRatio: "--",
pointerPosition: 0,
systemMessage: "",
});
const {
timerMinutes,
timerSeconds,
runCountdown,
syncDisplay,
clear,
timerRunning,
timerIsOver,
checkAudioPermission,
requestAudioPermission,
} = useTimer();
const progress = computed(() => ((timerMinutes.value * 60 + timerSeconds.value) / (25 * 60)) * 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") {
const normalized = value.includes("T") ? value : value.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();
}
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("任务暂停");
clear();
};
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) {
ElMessage.success("任务结束");
} else {
ElMessage.error(res.message || "结束会话失败");
}
clear();
await router.push("/study");
} catch {
ElMessage.error("结束会话失败,请重试");
}
};
const openSummaryDialog = () => {
summaryDialogVisible.value = true;
};
const closeSummaryDialog = () => {
summaryDialogVisible.value = false;
};
const restTimer = async () => {
await stopTimer();
runCountdown(5 * 60 * 1000);
};
const loadTaskSession = async () => {
try {
const res = await startOrContinueStudySession(taskNum);
Object.assign(taskInfo.value, res.data);
if (taskInfo.value.sessionState === "ONGOING") {
startTimer();
} else {
syncDisplay(taskInfo.value.pointerPosition || 25 * 60 * 1000);
}
} catch (err: any) {
ElMessage.error(err.message || "加载任务失败");
}
};
const initPage = async () => {
const hasPermission = await checkAudioPermission();
if (!hasPermission) {
const activated = await requestAudioPermission();
if (!activated) return;
}
await loadTaskSession();
};
onMounted(() => {
displayInterval = window.setInterval(() => {
nowTimestamp.value = Date.now();
}, 1000);
initPage();
});
onUnmounted(() => {
if (displayInterval) {
clearInterval(displayInterval);
}
clear();
});
</script>
<template>
<section class="start-page">
<div class="task-info-bar">
<el-tag type="success" effect="plain">任务编号{{ taskInfo.taskNum }}</el-tag>
<span class="status-text">状态{{ statusText }}</span>
</div>
<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">当前计时</p>
<el-progress type="dashboard" :percentage="progress" :stroke-width="14" :width="250" color="#2f8f68">
<div class="timer-number">
{{ timerMinutes.toString().padStart(2, "0") }}:{{ timerSeconds.toString().padStart(2, "0") }}
</div>
</el-progress>
<p class="timer-tip">番茄钟默认 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>{{ statusText }}</strong>
</div>
</div>
<div class="action-row">
<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" :disabled="!timerRunning">生成残片</el-button>
<el-button v-if="timerIsOver" plain type="success" @click="restTimer">休息</el-button>
<el-button type="danger" plain @click="openSummaryDialog">结束会话</el-button>
</div>
</article>
</div>
</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="summaryDialogVisible" title="结束会话总结" width="520px">
<el-input
type="textarea"
v-model="summaryContent"
placeholder="请输入本次学习内容总结"
:rows="5"
/>
<template #footer>
<el-button @click="closeSummaryDialog">取消</el-button>
<el-button type="success" @click="endTimer(summaryContent)">确认结束</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.start-page {
display: flex;
flex-direction: column;
gap: 14px;
}
.task-info-bar {
display: flex;
align-items: center;
gap: 12px;
}
.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-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;
}
.action-row .el-button {
margin: 0;
}
@media (max-width: 1100px) {
.session-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.top-head {
flex-direction: column;
}
.info-grid {
grid-template-columns: 1fr;
}
.action-row {
flex-direction: column;
}
.action-row .el-button {
width: 100%;
}
}
</style>