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 {
|
||||
title: string;
|
||||
description: string;
|
||||
materialURL: string;
|
||||
lastLearningStatus: string;
|
||||
materialUrl: string;
|
||||
priority: number | string;
|
||||
taskNum: string;
|
||||
taskId: number;
|
||||
@@ -52,18 +51,33 @@ 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 urlTitles = ref<string[]>([]);
|
||||
async function loadUrlTitles() {
|
||||
const urls = urlList.value;
|
||||
if (urls.length === 0) { urlTitles.value = []; return; }
|
||||
urlTitles.value = await fetchTitles(urls);
|
||||
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const escapeAttr = (s: string) => s.replace(/"/g, """).replace(/&/g, "&");
|
||||
|
||||
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}__`;
|
||||
});
|
||||
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分钟";
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user