feat: Study 页面学习材料 Markdown 渲染
- 接口 materialURL/materialUrl,移除 lastLearningStatus - 新增 Markdown 渲染函数:支持 [text](url) 和裸链接 - 学习材料区从 URL 列表改为 HTML 渲染展示 - 任务描述从只读 textarea 改为纯文本段落 - 优化 detail-block 分隔线、label 样式 - material-content 链接样式(绿色虚线+箭头图标) - 微调统计标签/数值字号
This commit is contained in:
+72
-42
@@ -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,18 +51,33 @@ 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
const urlList = computed(() => {
|
const escapeAttr = (s: string) => s.replace(/"/g, """).replace(/&/g, "&");
|
||||||
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 "";
|
||||||
const urlTitles = ref<string[]>([]);
|
let html = raw;
|
||||||
async function loadUrlTitles() {
|
const links: string[] = [];
|
||||||
const urls = urlList.value;
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m: string, text: string, url: string) => {
|
||||||
if (urls.length === 0) { urlTitles.value = []; return; }
|
const tag = `<a href="${escapeAttr(url)}" target="_blank" rel="noopener">${escapeHtml(text)}</a>`;
|
||||||
urlTitles.value = await fetchTitles(urls);
|
links.push(tag);
|
||||||
|
return `__LINK_${links.length - 1}__`;
|
||||||
|
});
|
||||||
|
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 => {
|
const formatEffectiveTime = (seconds: number): string => {
|
||||||
if (!seconds || seconds <= 0) return "0分钟";
|
if (!seconds || seconds <= 0) return "0分钟";
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user