feat(markdown): 只读内容支持 Markdown 渲染
- 新增 MarkdownRenderer.vue 组件,基于 marked 解析 GFM
- 学习残片/报告历史列表:纯文本 {{ }} 替换为 MarkdownRenderer
- ReviewDetail.vue content-body:p 标签逐行渲染替换为 MarkdownRenderer
- 结束会话总结框:增加「编辑/预览」切换,预览模式实时渲染 markdown
- 学习预期展示也改为 MarkdownRenderer
This commit is contained in:
Generated
+17
@@ -12,6 +12,7 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"element-plus": "^2.8.7",
|
"element-plus": "^2.8.7",
|
||||||
|
"marked": "^18.0.5",
|
||||||
"mind-elixir": "^5.13.0",
|
"mind-elixir": "^5.13.0",
|
||||||
"normalize": "^0.3.1",
|
"normalize": "^0.3.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
@@ -2683,6 +2684,17 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz",
|
||||||
|
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/memoize-one": {
|
"node_modules/memoize-one": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
@@ -6453,6 +6465,11 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"marked": {
|
||||||
|
"version": "18.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz",
|
||||||
|
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="
|
||||||
|
},
|
||||||
"memoize-one": {
|
"memoize-one": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"element-plus": "^2.8.7",
|
"element-plus": "^2.8.7",
|
||||||
|
"marked": "^18.0.5",
|
||||||
"mind-elixir": "^5.13.0",
|
"mind-elixir": "^5.13.0",
|
||||||
"normalize": "^0.3.1",
|
"normalize": "^0.3.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
content: string;
|
||||||
|
}>(), {
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = computed(() => {
|
||||||
|
if (!props.content) return "";
|
||||||
|
return marked.parse(props.content, { breaks: true, gfm: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="markdown-body" v-html="rendered"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.markdown-body {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #333;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(h1),
|
||||||
|
.markdown-body :deep(h2),
|
||||||
|
.markdown-body :deep(h3),
|
||||||
|
.markdown-body :deep(h4) {
|
||||||
|
margin: 0.75em 0 0.5em;
|
||||||
|
color: var(--green-900, #1b5e20);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(h1) { font-size: 1.4em; }
|
||||||
|
.markdown-body :deep(h2) { font-size: 1.25em; }
|
||||||
|
.markdown-body :deep(h3) { font-size: 1.1em; }
|
||||||
|
|
||||||
|
.markdown-body :deep(p) {
|
||||||
|
margin: 0.4em 0;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(ul),
|
||||||
|
.markdown-body :deep(ol) {
|
||||||
|
margin: 0.4em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(li) {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(code) {
|
||||||
|
background: #f0f4f0;
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(pre) {
|
||||||
|
background: #f5f7f5;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #e0e8e0;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(blockquote) {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
border-left: 3px solid var(--green-600, #2f8f68);
|
||||||
|
background: #f1f8e9;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(a) {
|
||||||
|
color: var(--green-600, #2f8f68);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(a:hover) {
|
||||||
|
color: var(--green-900, #1b5e20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e0e8e0;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(th),
|
||||||
|
.markdown-body :deep(td) {
|
||||||
|
border: 1px solid #d0d8d0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(th) {
|
||||||
|
background: #e8f5e9;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { getSessionDetail } from "@/api/studySessions";
|
import { getSessionDetail } from "@/api/studySessions";
|
||||||
import { updateFragments } from "@/api/reportFragments";
|
import { updateFragments } from "@/api/reportFragments";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
|
import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -301,9 +302,7 @@ watch(
|
|||||||
<!-- 只读模式 -->
|
<!-- 只读模式 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="content-body" v-if="content">
|
<div class="content-body" v-if="content">
|
||||||
<p v-for="(line, idx) in content.split('\n')" :key="idx">
|
<MarkdownRenderer :content="content" />
|
||||||
{{ line || " " }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-content">暂无内容</div>
|
<div v-else class="empty-content">暂无内容</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useStudyFragment } from "@/components/composables/fragment";
|
|||||||
import { getFragmentsBySession, updateFragments } from "@/api/reportFragments";
|
import { getFragmentsBySession, updateFragments } from "@/api/reportFragments";
|
||||||
import { getExpectation, getReportDraft, getTaskFragments, getTaskReports, upsertExpectation } from "@/api/studySessions";
|
import { getExpectation, getReportDraft, getTaskFragments, getTaskReports, upsertExpectation } from "@/api/studySessions";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
|
import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fragmentsDialogVisible,
|
fragmentsDialogVisible,
|
||||||
@@ -24,6 +25,7 @@ const {
|
|||||||
|
|
||||||
const summaryDialogVisible = ref(false);
|
const summaryDialogVisible = ref(false);
|
||||||
const summaryContent = ref("");
|
const summaryContent = ref("");
|
||||||
|
const summaryPreview = ref(false);
|
||||||
const summaryLoading = ref(false);
|
const summaryLoading = ref(false);
|
||||||
const summaryLoadingSeconds = ref(0);
|
const summaryLoadingSeconds = ref(0);
|
||||||
let summaryLoadingTimer: ReturnType<typeof setInterval> | null = null;
|
let summaryLoadingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -666,7 +668,7 @@ onUnmounted(() => {
|
|||||||
<div v-loading="loadingHistory" class="history-list">
|
<div v-loading="loadingHistory" class="history-list">
|
||||||
<div v-for="item in historyFragments" :key="item.id" class="history-item">
|
<div v-for="item in historyFragments" :key="item.id" class="history-item">
|
||||||
<span class="session-tag">{{ item.sessionNum }}</span>
|
<span class="session-tag">{{ item.sessionNum }}</span>
|
||||||
<span class="history-content">{{ item.content }}</span>
|
<MarkdownRenderer :content="item.content" />
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!loadingHistory && historyFragments.length === 0" description="暂未找到匹配的残片" :image-size="48"></el-empty>
|
<el-empty v-if="!loadingHistory && historyFragments.length === 0" description="暂未找到匹配的残片" :image-size="48"></el-empty>
|
||||||
</div>
|
</div>
|
||||||
@@ -676,7 +678,7 @@ onUnmounted(() => {
|
|||||||
<div v-loading="loadingHistory" class="history-list">
|
<div v-loading="loadingHistory" class="history-list">
|
||||||
<div v-for="item in historyReports" :key="item.id" class="history-item report-item">
|
<div v-for="item in historyReports" :key="item.id" class="history-item report-item">
|
||||||
<span class="session-tag">{{ item.sessionNum }}</span>
|
<span class="session-tag">{{ item.sessionNum }}</span>
|
||||||
<p class="history-content">{{ item.content }}</p>
|
<MarkdownRenderer :content="item.content" />
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="!loadingHistory && historyReports.length === 0" description="暂未找到匹配的报告" :image-size="48" />
|
<el-empty v-if="!loadingHistory && historyReports.length === 0" description="暂未找到匹配的报告" :image-size="48" />
|
||||||
</div>
|
</div>
|
||||||
@@ -721,21 +723,39 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="summaryDialogVisible" title="结束会话总结" width="560px">
|
<el-dialog v-model="summaryDialogVisible" title="结束会话总结" width="640px">
|
||||||
<div v-if="expectationSaved" class="summary-expectation">
|
<div v-if="expectationSaved" class="summary-expectation">
|
||||||
<span class="expectation-label">本次预期</span>
|
<span class="expectation-label">本次预期</span>
|
||||||
<span>{{ expectationSaved }}</span>
|
<MarkdownRenderer :content="expectationSaved" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="summaryLoading" class="ai-waiting">
|
<p v-if="summaryLoading" class="ai-waiting">
|
||||||
AI 正在聚合残片生成草稿,已等待 {{ summaryLoadingSeconds }} 秒…
|
AI 正在聚合残片生成草稿,已等待 {{ summaryLoadingSeconds }} 秒…
|
||||||
</p>
|
</p>
|
||||||
<el-input
|
<div class="summary-editor">
|
||||||
v-loading="summaryLoading"
|
<div class="summary-toolbar">
|
||||||
type="textarea"
|
<el-button
|
||||||
v-model="summaryContent"
|
size="small"
|
||||||
placeholder="总结本次学到的内容(已有残片时自动生成草稿,可修改)"
|
:type="summaryPreview ? '' : 'primary'"
|
||||||
:rows="8"
|
@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>
|
<template #footer>
|
||||||
<el-button @click="closeSummaryDialog">取消</el-button>
|
<el-button @click="closeSummaryDialog">取消</el-button>
|
||||||
<el-button type="success" @click="endTimer(summaryContent)">确认结束</el-button>
|
<el-button type="success" @click="endTimer(summaryContent)">确认结束</el-button>
|
||||||
@@ -819,6 +839,33 @@ onUnmounted(() => {
|
|||||||
color: var(--text-secondary);
|
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 {
|
.status-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
Reference in New Issue
Block a user