feat: Study 页面学习材料 Markdown 渲染

- 接口 materialURL/materialUrl,移除 lastLearningStatus
- 新增 Markdown 渲染函数:支持 [text](url) 和裸链接
- 学习材料区从 URL 列表改为 HTML 渲染展示
- 任务描述从只读 textarea 改为纯文本段落
- 优化 detail-block 分隔线、label 样式
- material-content 链接样式(绿色虚线+箭头图标)
- 微调统计标签/数值字号
This commit is contained in:
developer
2026-07-12 13:48:28 +08:00
parent 39bf6b7426
commit a81f83f27c
+71 -41
View File
@@ -19,8 +19,7 @@ import { getUrlTitle } from "@/utils/fetchTitle";
interface TaskItem { interface TaskItem {
title: string; title: string;
description: string; description: string;
materialURL: string; materialUrl: string;
lastLearningStatus: string;
priority: number | string; priority: number | string;
taskNum: string; taskNum: string;
taskId: number; taskId: number;
@@ -52,17 +51,32 @@ const selectedTask = computed(() =>
tasks.value.find((item) => item.taskId === selectedTaskId.value) || null tasks.value.find((item) => item.taskId === selectedTaskId.value) || null
); );
// 多 URL 支持 const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const urlList = computed(() => { const escapeAttr = (s: string) => s.replace(/"/g, "&quot;").replace(/&/g, "&amp;");
const raw = selectedTask.value?.materialURL || "";
return raw.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.startsWith("http")); function renderMarkdown(raw: string): string {
if (!raw.trim()) return "";
let html = raw;
const links: string[] = [];
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m: string, text: string, url: string) => {
const tag = `<a href="${escapeAttr(url)}" target="_blank" rel="noopener">${escapeHtml(text)}</a>`;
links.push(tag);
return `__LINK_${links.length - 1}__`;
}); });
const urlTitles = ref<string[]>([]); html = html.replace(/https?:\/\/[^\s)\u3001\uFF09\u300D\u300B<>]+/g, (url) => {
async function loadUrlTitles() { const clean = url.replace(/[.。,;!!???)]」》]]+$/, "");
const urls = urlList.value; const tag = `<a href="${escapeAttr(clean)}" target="_blank" rel="noopener">${escapeHtml(clean)}</a>`;
if (urls.length === 0) { urlTitles.value = []; return; } links.push(tag);
urlTitles.value = await fetchTitles(urls); return `__LINK_${links.length - 1}__`;
});
html = html.replace(/\n/g, "<br>");
for (let i = 0; i < links.length; i++) {
html = html.replace(`__LINK_${i}__`, links[i]);
} }
return html;
}
const materialHtml = computed(() => renderMarkdown(selectedTask.value?.materialUrl || ""));
const formatEffectiveTime = (seconds: number): string => { const formatEffectiveTime = (seconds: number): string => {
if (!seconds || seconds <= 0) return "0分钟"; if (!seconds || seconds <= 0) return "0分钟";
@@ -141,8 +155,7 @@ const fetchTasks = async () => {
tasks.value = (res?.data?.records || []).map((record: any) => ({ tasks.value = (res?.data?.records || []).map((record: any) => ({
title: record.taskName, title: record.taskName,
description: record.taskDescription || "", description: record.taskDescription || "",
materialURL: record.materialURL || record.materialUrl || "", materialUrl: record.materialUrl || "",
lastLearningStatus: record.lastLearningStatus || "未开始",
priority: Number.isFinite(record.taskPriority) ? Math.round(record.taskPriority) : "-", priority: Number.isFinite(record.taskPriority) ? Math.round(record.taskPriority) : "-",
taskNum: record.taskNum, taskNum: record.taskNum,
taskId: record.id, taskId: record.id,
@@ -324,7 +337,6 @@ onMounted(() => {
<span class="task-priority">{{ item.priority }}</span> <span class="task-priority">{{ item.priority }}</span>
{{ item.title }} {{ item.title }}
</p> </p>
<p class="task-meta">状态{{ item.lastLearningStatus }}</p>
</div> </div>
</el-scrollbar> </el-scrollbar>
</aside> </aside>
@@ -339,18 +351,12 @@ onMounted(() => {
<div class="detail-block"> <div class="detail-block">
<p class="label">任务描述</p> <p class="label">任务描述</p>
<el-input type="textarea" :model-value="selectedTask.description" :rows="4" readonly /> <p class="description-text">{{ selectedTask.description || '暂无描述' }}</p>
</div> </div>
<div class="detail-block"> <div class="detail-block">
<p class="label">学习材料</p> <p class="label">学习材料</p>
<div v-if="urlList.length > 0" class="material-list"> <div v-if="materialHtml" class="material-content" v-html="materialHtml" />
<div v-for="(url, i) in urlList" :key="i" class="material-item">
<el-link :href="url" target="_blank" type="success">
{{ urlTitles[i] || url }}
</el-link>
</div>
</div>
<p v-else class="empty-text">暂未填写材料地址</p> <p v-else class="empty-text">暂未填写材料地址</p>
</div> </div>
@@ -578,11 +584,6 @@ onMounted(() => {
justify-content: center; justify-content: center;
} }
.task-meta {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-secondary);
}
.content-zone { .content-zone {
display: grid; display: grid;
@@ -606,14 +607,33 @@ onMounted(() => {
font-size: 22px; font-size: 22px;
} }
.description-text {
margin: 0;
font-size: 14px;
line-height: 1.7;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.detail-block { .detail-block {
margin-top: 16px; margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border-soft);
}
.detail-block:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
} }
.label { .label {
margin: 0 0 8px; margin: 0 0 10px;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 13px; font-size: 14px;
font-weight: 500;
letter-spacing: 0.5px;
} }
.empty-text { .empty-text {
@@ -621,19 +641,29 @@ onMounted(() => {
color: var(--text-secondary); color: var(--text-secondary);
} }
.material-list { .material-content {
display: flex; font-size: 14px;
flex-direction: column; line-height: 1.8;
gap: 6px; word-break: break-word;
color: var(--text-primary);
} }
.material-item { .material-content :deep(a) {
padding: 4px 0; color: var(--green-600);
word-break: break-all; text-decoration: none;
border-bottom: 1px dashed var(--green-400);
transition: color 0.15s, border-color 0.15s;
} }
.material-item .el-link { .material-content :deep(a:hover) {
font-size: 13px; color: var(--green-800);
border-bottom-style: solid;
}
.material-content :deep(a)::after {
content: " ↗";
font-size: 11px;
opacity: 0.5;
} }
.stats-grid { .stats-grid {
@@ -649,12 +679,12 @@ onMounted(() => {
} }
.stat-label { .stat-label {
font-size: 12px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
} }
.stat-value { .stat-value {
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
} }