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 {
title: string;
description: string;
materialURL: string;
lastLearningStatus: string;
materialUrl: string;
priority: number | string;
taskNum: string;
taskId: number;
@@ -52,17 +51,32 @@ const selectedTask = computed(() =>
tasks.value.find((item) => item.taskId === selectedTaskId.value) || null
);
// 多 URL 支持
const urlList = computed(() => {
const raw = selectedTask.value?.materialURL || "";
return raw.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.startsWith("http"));
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escapeAttr = (s: string) => s.replace(/"/g, "&quot;").replace(/&/g, "&amp;");
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[]>([]);
async function loadUrlTitles() {
const urls = urlList.value;
if (urls.length === 0) { urlTitles.value = []; return; }
urlTitles.value = await fetchTitles(urls);
html = html.replace(/https?:\/\/[^\s)\u3001\uFF09\u300D\u300B<>]+/g, (url) => {
const clean = url.replace(/[.。,;!!???)]」》]]+$/, "");
const tag = `<a href="${escapeAttr(clean)}" target="_blank" rel="noopener">${escapeHtml(clean)}</a>`;
links.push(tag);
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 => {
if (!seconds || seconds <= 0) return "0分钟";
@@ -141,8 +155,7 @@ const fetchTasks = async () => {
tasks.value = (res?.data?.records || []).map((record: any) => ({
title: record.taskName,
description: record.taskDescription || "",
materialURL: record.materialURL || record.materialUrl || "",
lastLearningStatus: record.lastLearningStatus || "未开始",
materialUrl: record.materialUrl || "",
priority: Number.isFinite(record.taskPriority) ? Math.round(record.taskPriority) : "-",
taskNum: record.taskNum,
taskId: record.id,
@@ -324,7 +337,6 @@ onMounted(() => {
<span class="task-priority">{{ item.priority }}</span>
{{ item.title }}
</p>
<p class="task-meta">状态{{ item.lastLearningStatus }}</p>
</div>
</el-scrollbar>
</aside>
@@ -339,18 +351,12 @@ onMounted(() => {
<div class="detail-block">
<p class="label">任务描述</p>
<el-input type="textarea" :model-value="selectedTask.description" :rows="4" readonly />
<p class="description-text">{{ selectedTask.description || '暂无描述' }}</p>
</div>
<div class="detail-block">
<p class="label">学习材料</p>
<div v-if="urlList.length > 0" class="material-list">
<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>
<div v-if="materialHtml" class="material-content" v-html="materialHtml" />
<p v-else class="empty-text">暂未填写材料地址</p>
</div>
@@ -578,11 +584,6 @@ onMounted(() => {
justify-content: center;
}
.task-meta {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-secondary);
}
.content-zone {
display: grid;
@@ -606,14 +607,33 @@ onMounted(() => {
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 {
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 {
margin: 0 0 8px;
margin: 0 0 10px;
color: var(--text-secondary);
font-size: 13px;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.5px;
}
.empty-text {
@@ -621,19 +641,29 @@ onMounted(() => {
color: var(--text-secondary);
}
.material-list {
display: flex;
flex-direction: column;
gap: 6px;
.material-content {
font-size: 14px;
line-height: 1.8;
word-break: break-word;
color: var(--text-primary);
}
.material-item {
padding: 4px 0;
word-break: break-all;
.material-content :deep(a) {
color: var(--green-600);
text-decoration: none;
border-bottom: 1px dashed var(--green-400);
transition: color 0.15s, border-color 0.15s;
}
.material-item .el-link {
font-size: 13px;
.material-content :deep(a:hover) {
color: var(--green-800);
border-bottom-style: solid;
}
.material-content :deep(a)::after {
content: " ↗";
font-size: 11px;
opacity: 0.5;
}
.stats-grid {
@@ -649,12 +679,12 @@ onMounted(() => {
}
.stat-label {
font-size: 12px;
font-size: 13px;
color: var(--text-secondary);
}
.stat-value {
font-size: 14px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}