feat(预期):学习预期闭环 + 权重配置 UI
- StartTask:新会话强制填写学习预期(不可跳过),页面顶部常驻展示可修改 - 结束会话弹窗:展示本次预期供对照,有残片时自动拉取 AI 聚合草稿作为编辑起点 - Study:常用操作增加权重配置——五维度滑块、合计校验 100%、保存后重算并刷新 - api:expectation / report-draft / priority-weights 接口
This commit is contained in:
@@ -21,3 +21,18 @@ export const startOrContinueStudySession = (taskNum: string) => {
|
||||
export const getSessionDetail = (sessionNum: string) => {
|
||||
return request.get(`/study-sessions/${sessionNum}`);
|
||||
};
|
||||
|
||||
/** 获取会话的学习预期 */
|
||||
export const getExpectation = (sessionNum: string) => {
|
||||
return request.get(`/study-sessions/${sessionNum}/expectation`);
|
||||
};
|
||||
|
||||
/** 创建/更新会话的学习预期 */
|
||||
export const upsertExpectation = (sessionNum: string, description: string) => {
|
||||
return request.put(`/study-sessions/${sessionNum}/expectation`, { description });
|
||||
};
|
||||
|
||||
/** 获取报告草稿(AI 聚合残片,不可用时为拼接) */
|
||||
export const getReportDraft = (sessionNum: string) => {
|
||||
return request.get(`/study-sessions/${sessionNum}/report-draft`);
|
||||
};
|
||||
|
||||
@@ -42,3 +42,22 @@ export const updateTaskApplication = (
|
||||
export const deleteTaskApplication = (id: number) => {
|
||||
return request.del(`/tasks/applications/${id}`);
|
||||
};
|
||||
|
||||
// ============ 优先级权重配置 ============
|
||||
|
||||
export interface PriorityWeights {
|
||||
urgencyWeight: number;
|
||||
importanceWeight: number;
|
||||
contentDifficultyWeight: number;
|
||||
futureValueWeight: number;
|
||||
subjectivePriorityWeight: number;
|
||||
}
|
||||
|
||||
export const getPriorityWeights = () => {
|
||||
return request.get("/tasks/priority-weights");
|
||||
};
|
||||
|
||||
/** 保存权重配置并触发全部任务优先级重算 */
|
||||
export const savePriorityWeights = (weights: PriorityWeights) => {
|
||||
return request.put("/tasks/priority-weights", weights);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/api/studySessions";
|
||||
import { useStudyFragment } from "@/components/composables/fragment";
|
||||
import { getFragmentsBySession, updateFragments } from "@/api/reportFragments";
|
||||
import { getExpectation, getReportDraft, upsertExpectation } from "@/api/studySessions";
|
||||
import router from "@/router";
|
||||
|
||||
const {
|
||||
@@ -23,6 +24,13 @@ const {
|
||||
|
||||
const summaryDialogVisible = ref(false);
|
||||
const summaryContent = ref("");
|
||||
const summaryLoading = ref(false);
|
||||
|
||||
// 学习预期
|
||||
const expectationDialogVisible = ref(false);
|
||||
const expectationContent = ref("");
|
||||
const expectationSaved = ref("");
|
||||
const savingExpectation = ref(false);
|
||||
|
||||
// 当前会话碎片列表
|
||||
interface FragmentItem {
|
||||
@@ -209,8 +217,22 @@ const endTimer = async (content: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const openSummaryDialog = () => {
|
||||
const openSummaryDialog = async () => {
|
||||
summaryDialogVisible.value = true;
|
||||
// 有残片且未填写过总结时,用 AI 聚合草稿作为编辑起点
|
||||
if (!summaryContent.value.trim() && fragmentsList.value.length > 0) {
|
||||
summaryLoading.value = true;
|
||||
try {
|
||||
const res = await getReportDraft(taskInfo.value.sessionNum);
|
||||
if (res?.code === 200 && res.data) {
|
||||
summaryContent.value = res.data;
|
||||
}
|
||||
} catch {
|
||||
// 草稿失败不阻塞手写
|
||||
} finally {
|
||||
summaryLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeSummaryDialog = () => {
|
||||
@@ -226,6 +248,7 @@ const loadTaskSession = async () => {
|
||||
try {
|
||||
const res = await startOrContinueStudySession(taskNum);
|
||||
Object.assign(taskInfo.value, res.data);
|
||||
await loadExpectation();
|
||||
if (taskInfo.value.sessionState === "ONGOING") {
|
||||
startTimer();
|
||||
} else {
|
||||
@@ -237,6 +260,41 @@ const loadTaskSession = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 学习预期:会话必须有预期才能开始计时
|
||||
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) {
|
||||
@@ -323,6 +381,19 @@ onUnmounted(() => {
|
||||
<span class="status-text">状态:{{ statusText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 学习预期 -->
|
||||
<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"
|
||||
@click="expectationContent = expectationSaved; expectationDialogVisible = true"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
</article>
|
||||
|
||||
<el-alert
|
||||
v-if="taskInfo.systemMessage"
|
||||
:title="taskInfo.systemMessage"
|
||||
@@ -415,12 +486,38 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="summaryDialogVisible" title="结束会话总结" width="520px">
|
||||
<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="560px">
|
||||
<div v-if="expectationSaved" class="summary-expectation">
|
||||
<span class="expectation-label">本次预期</span>
|
||||
<span>{{ expectationSaved }}</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-loading="summaryLoading"
|
||||
type="textarea"
|
||||
v-model="summaryContent"
|
||||
placeholder="请输入本次学习内容总结"
|
||||
:rows="5"
|
||||
placeholder="总结本次学到的内容(已有残片时自动生成草稿,可修改)"
|
||||
:rows="8"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="closeSummaryDialog">取消</el-button>
|
||||
@@ -443,6 +540,45 @@ onUnmounted(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
.dialog-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-expectation {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -5,8 +5,11 @@ import router from "@/router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { getReviewTaskStatsByTask } from "@/api/review";
|
||||
import {
|
||||
getPriorityWeights,
|
||||
getTaskApplications,
|
||||
savePriorityWeights,
|
||||
updateTaskApplication,
|
||||
type PriorityWeights,
|
||||
type TaskApplication,
|
||||
} from "@/api/tasks";
|
||||
|
||||
@@ -190,6 +193,62 @@ const removeTask = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 优先级权重配置 ============
|
||||
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();
|
||||
});
|
||||
@@ -320,11 +379,32 @@ onMounted(() => {
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -334,6 +414,51 @@ onMounted(() => {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user