Files
lpt-fe/src/components/ReviewDetail.vue
T
cat-shark 4abc316311 fix: ReviewDetail 内容居中与文字换行
- 页面内容 margin: 0 auto 居中
- 内容区和历史记录添加 word-break 防止长文本溢出
- 移除已删除的 .detail-head 死 CSS 和页面 header
2026-05-23 22:55:06 +08:00

345 lines
8.2 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getReportDetail, getFragmentDetail, getTaskReview, type ReviewFeedItem } from "@/api/review";
import { getSessionDetail } from "@/api/studySessions";
const route = useRoute();
const router = useRouter();
const contentType = computed(() => route.params.type as string);
const contentId = computed(() => Number(route.params.id));
const loading = ref(true);
const content = ref("");
const sourceTypeLabel = ref("");
const taskNum = ref("");
// 当前会话信息
const sessionInfo = ref<{
taskName: string;
sessionNum: string;
actualTime?: number;
effectiveTime?: number;
effectivenessRatio?: number;
startTime?: string;
} | null>(null);
// 该任务下的所有学习记录(按 session 分组)
const taskSessions = ref<{
sessionNum: string;
startTime: string;
report: ReviewFeedItem | null;
fragments: ReviewFeedItem[];
}[]>([]);
const formatDuration = (seconds: number): string => {
if (seconds == null) return "-";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}小时${m}分钟`;
if (m > 0) return `${m}分钟${s}`;
return `${s}`;
};
const loadDetail = async () => {
loading.value = true;
try {
let res;
if (contentType.value === "report") {
res = await getReportDetail(contentId.value);
sourceTypeLabel.value = "学习报告";
} else {
res = await getFragmentDetail(contentId.value);
sourceTypeLabel.value = "学习残片";
}
const data = res?.data;
content.value = data?.content || "";
const sessionNum = data?.sessionNum;
if (sessionNum) {
try {
const sessionRes = await getSessionDetail(sessionNum);
const info = sessionRes?.data;
sessionInfo.value = info || null;
taskNum.value = info?.taskNum || "";
// 加载该任务下的所有报告和残片
if (info?.taskNum) {
await loadTaskHistory(info.taskNum, sessionNum);
}
} catch {
// 非关键数据
}
}
} finally {
loading.value = false;
}
};
const loadTaskHistory = async (tn: string, currentSessionNum: string) => {
try {
const res = await getTaskReview(tn);
const items: ReviewFeedItem[] = res?.data || [];
// 按 sessionNum 分组
const map = new Map<string, { report: ReviewFeedItem | null; fragments: ReviewFeedItem[]; startTime: string }>();
for (const item of items) {
const entry = map.get(item.sessionNum) || { report: null, fragments: [], startTime: item.createdTime };
if (item.sourceType === "REPORT") {
entry.report = item;
} else {
entry.fragments.push(item);
}
if (!map.has(item.sessionNum)) {
map.set(item.sessionNum, entry);
}
}
taskSessions.value = Array.from(map.entries())
.map(([sessionNum, data]) => ({
sessionNum,
startTime: data.startTime,
report: data.report,
fragments: data.fragments,
}))
.sort((a, b) => b.startTime.localeCompare(a.startTime));
} catch {
// 非关键数据
}
};
const goToDetail = (type: string, id: number) => {
router.push(`/review/detail/${type}/${id}`);
};
onMounted(() => {
loadDetail();
});
</script>
<template>
<section class="review-detail-page">
<!-- 当前条目内容 -->
<article class="surface-card content-card" v-loading="loading">
<div class="content-meta">
<el-tag
:type="contentType === 'report' ? 'success' : 'warning'"
effect="dark"
size="small"
>
{{ sourceTypeLabel }}
</el-tag>
<span v-if="sessionInfo" class="meta-task-name">
任务{{ sessionInfo.taskName }}
</span>
<span v-if="sessionInfo?.startTime" class="meta-time">
学习时间{{ sessionInfo.startTime?.replace("T", " ")?.substring(0, 16) }}
</span>
</div>
<div class="content-body" v-if="content">
<p v-for="(line, idx) in content.split('\n')" :key="idx">
{{ line || " " }}
</p>
</div>
<div v-else class="empty-content">暂无内容</div>
</article>
<!-- 该任务下的所有学习记录 -->
<article class="surface-card history-card" v-if="taskSessions.length > 0">
<h3>该任务下的所有学习记录</h3>
<div
v-for="session in taskSessions"
:key="session.sessionNum"
class="history-session"
:class="{ 'is-current': session.sessionNum === sessionInfo?.sessionNum }"
>
<div class="session-header">
<span class="session-date">
{{ session.startTime?.replace("T", " ")?.substring(0, 16) }}
</span>
<span class="session-badge" v-if="session.sessionNum === sessionInfo?.sessionNum">当前</span>
</div>
<div class="session-report" v-if="session.report">
<span
class="clickable-text"
:class="{ active: contentType === 'report' && session.report.id === contentId }"
@click="goToDetail('report', session.report!.id)"
>
<span class="label-tag report-tag">报告</span>
{{ session.report.content.length > 150 ? session.report.content.substring(0, 150) + "..." : session.report.content }}
</span>
</div>
<div class="session-fragments" v-if="session.fragments.length > 0">
<span
v-for="f in session.fragments"
:key="f.id"
class="clickable-text"
:class="{ active: contentType === 'fragment' && f.id === contentId }"
@click="goToDetail('fragment', f.id)"
>
<span class="label-tag fragment-tag">残片</span>
{{ f.content.length > 150 ? f.content.substring(0, 150) + "..." : f.content }}
</span>
</div>
</div>
</article>
</section>
</template>
<style scoped>
.review-detail-page {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 900px;
margin: 0 auto;
}
.content-card {
padding: 22px;
min-height: 120px;
}
.content-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.meta-task-name {
font-weight: 600;
color: var(--green-800);
font-size: 14px;
}
.meta-time {
color: var(--text-secondary);
font-size: 13px;
}
.content-body {
line-height: 1.8;
color: var(--text-primary);
font-size: 15px;
word-break: break-word;
overflow-wrap: break-word;
}
.content-body p {
margin: 0;
min-height: 1em;
}
.empty-content {
color: var(--text-secondary);
text-align: center;
padding: 40px 0;
}
/* 历史记录卡片 */
.history-card {
padding: 20px 22px;
}
.history-card h3 {
margin: 0 0 16px;
color: var(--green-900);
font-size: 16px;
}
.history-session {
padding: 14px 0;
border-bottom: 1px solid var(--border-soft);
}
.history-session:last-child {
border-bottom: none;
}
.history-session.is-current {
background: #f1f8e9;
margin: 0 -10px;
padding: 14px 10px;
border-radius: 8px;
}
.session-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.session-date {
font-size: 12px;
color: var(--text-secondary);
}
.session-badge {
font-size: 11px;
background: var(--green-600);
color: #fff;
padding: 1px 8px;
border-radius: 10px;
}
.session-report {
margin-bottom: 6px;
}
.session-fragments {
display: flex;
flex-direction: column;
gap: 4px;
}
.clickable-text {
display: block;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s;
word-break: break-word;
overflow-wrap: break-word;
}
.clickable-text:hover {
background: var(--surface-strong);
}
.clickable-text.active {
background: #e8f5e9;
border-left: 3px solid var(--green-600);
}
.label-tag {
display: inline-block;
font-size: 11px;
padding: 0 6px;
border-radius: 3px;
margin-right: 6px;
font-weight: 600;
vertical-align: middle;
}
.report-tag {
background: #e8f5e9;
color: var(--green-700);
}
.fragment-tag {
background: #fff8e1;
color: #b8860b;
}
</style>