Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c08c247271 | |||
| 2315c0d08f | |||
| 032317da7b | |||
| 9c3c1f0d26 | |||
| 6accec1b2a | |||
| 73d0572dfc | |||
| a81f83f27c | |||
| 39bf6b7426 | |||
| a67d092805 | |||
| 52b59019d0 | |||
| 59dea6f84c |
@@ -0,0 +1,46 @@
|
||||
# LPT 前端规范
|
||||
|
||||
## 技术栈
|
||||
- Vue 3 + TypeScript + Vite
|
||||
- Element Plus UI
|
||||
- Axios(`src/utils/request.ts`)
|
||||
- mind-elixir(思维导图)
|
||||
|
||||
## 认证与请求
|
||||
- axios `withCredentials: true`,Cookie `satoken` 自动携带
|
||||
- 401 处理(`handleError`):HTTP 层和业务层双重检测,清 `localStorage.isLoggedIn` → `router.push("/login")`
|
||||
- `validateResponse`:`code !== 200` → reject
|
||||
|
||||
## Markdown 学习材料
|
||||
- 编辑:textarea 输入,支持 `[文字](url)` 和裸 URL
|
||||
- 预览/展示:`renderMarkdown(raw)` 同步函数
|
||||
- 使用占位符 `__LINK_N__` 避免正则二次匹配产生嵌套 HTML
|
||||
- `[文字](url)` → `<a>` 标签
|
||||
- 裸 URL → `<a>` 标签(展示时用 getUrlTitle 获取标题)
|
||||
- 保存:`convertMaterialUrls()` 在 createTask/updateTask 前异步拉取标题,替换裸 URL 为 `[标题](url)`
|
||||
- `getUrlTitle(url)`:调 `/utils/fetch-title`,内存缓存 + 请求去重
|
||||
|
||||
## 应用场景
|
||||
- TaskForm 编辑页:弹窗形式(el-dialog),列表展示用 `getUrlTitle` 获取链接标题
|
||||
- Study 详情页:同上
|
||||
|
||||
## 样式规范
|
||||
- 链接:绿色系(`var(--green-600)`),虚线下划线,hover 变实线
|
||||
- 区块分隔:`.detail-block` 有 `border-top` + `padding-top`,首个除外
|
||||
- 小字提示:12px `var(--text-secondary)`
|
||||
|
||||
## 任务清单分页
|
||||
- Study.vue:每页 20 条,`el-pagination` 在列表底部
|
||||
|
||||
## 学习会话页面
|
||||
- StartTask.vue 展示学习材料(可点击链接),支持弹窗编辑
|
||||
- 编辑时先 GET 任务详情,合并后 PUT 更新(保护其他字段不被覆盖)
|
||||
|
||||
## 思维导图
|
||||
- MindMapViewer 封装 mind-elixir
|
||||
- selectable 模式:点击节点 emit `node-select`
|
||||
- ReviewRecall 中 standardExpanded 展开后节点可点击选复习起点
|
||||
|
||||
## 编译
|
||||
- `npx vite build`
|
||||
- `npx vue-tsc --noEmit`(类型检查)
|
||||
@@ -121,7 +121,28 @@ const loadData = async () => {
|
||||
if (!recallTree.value) {
|
||||
recallTree.value = buildEmptyRecallTree();
|
||||
}
|
||||
// 若从 URL 带了 focusPath,自动设置回忆导图根节点
|
||||
if (focusPath.value && standard.value?.content) {
|
||||
try {
|
||||
const tree = JSON.parse(standard.value.content) as MindMapTreeNode;
|
||||
const match = findNodeByPath(tree, focusPath.value);
|
||||
if (match) {
|
||||
recallTree.value = { title: match.title, children: [] };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const findNodeByPath = (node: MindMapTreeNode, path: string): MindMapTreeNode | null => {
|
||||
const parts = path.split(" / ");
|
||||
let current: MindMapTreeNode | null = node;
|
||||
for (let i = 0; i < parts.length && current; i++) {
|
||||
if (current.title === parts[i] && i === parts.length - 1) return current;
|
||||
const child = (current.children || []).find((c) => c.title === parts[i]);
|
||||
current = child || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 重新生成(防抖 + 用户编辑时弹窗选模式)
|
||||
@@ -521,7 +542,7 @@ onMounted(() => {
|
||||
v-if="standardTree"
|
||||
:tree="compareTree || standardTree"
|
||||
:color-by-compare="!!compareTree"
|
||||
:selectable="!editingStandard && !focusPath && !hasCompared"
|
||||
:selectable="!editingStandard && !hasCompared"
|
||||
height="60vh"
|
||||
@node-select="handleNodeSelect"
|
||||
@node-click="goToNodeSource"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import request from "@/utils/request";
|
||||
import { useTimer } from "@/components/composables/useTimer";
|
||||
import {
|
||||
continueSession,
|
||||
@@ -133,6 +134,8 @@ const taskInfo = ref({
|
||||
sessionState: "--",
|
||||
taskName: "",
|
||||
taskNum,
|
||||
taskId: 0,
|
||||
materialUrl: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
lastStartTime: "",
|
||||
@@ -143,6 +146,65 @@ const taskInfo = ref({
|
||||
systemMessage: "",
|
||||
});
|
||||
|
||||
// 学习材料展示/编辑
|
||||
const editingMaterial = ref(false);
|
||||
const editMaterialContent = ref("");
|
||||
const savingMaterial = ref(false);
|
||||
const materialHtml = computed(() => {
|
||||
const raw = taskInfo.value.materialUrl || "";
|
||||
if (!raw.trim()) return "";
|
||||
let html = raw;
|
||||
const links: string[] = [];
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m: string, text: string, url: string) => {
|
||||
const tag = `<a href="${url.replace(/"/g, """)}" target="_blank" rel="noopener">${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="${clean.replace(/"/g, """)}" target="_blank" rel="noopener">${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;
|
||||
});
|
||||
|
||||
function startEditMaterial() {
|
||||
editMaterialContent.value = taskInfo.value.materialUrl;
|
||||
editingMaterial.value = true;
|
||||
}
|
||||
|
||||
async function saveMaterial() {
|
||||
savingMaterial.value = true;
|
||||
try {
|
||||
// 先拉取完整任务数据,只改 materialUrl,其他字段保持不变
|
||||
const res = await request.get(`/tasks/${taskInfo.value.taskId}`);
|
||||
const current = res?.data || {};
|
||||
await request.put(`/tasks/${taskInfo.value.taskId}`, {
|
||||
id: taskInfo.value.taskId,
|
||||
taskName: current.taskName,
|
||||
taskDescription: current.taskDescription || "",
|
||||
materialUrl: editMaterialContent.value,
|
||||
urgency: current.urgency ?? 0,
|
||||
importance: current.importance ?? 0,
|
||||
contentDifficulty: current.contentDifficulty ?? 0,
|
||||
futureValue: current.futureValue ?? 0,
|
||||
subjectivePriority: current.subjectivePriority ?? 0,
|
||||
});
|
||||
taskInfo.value.materialUrl = editMaterialContent.value;
|
||||
editingMaterial.value = false;
|
||||
ElMessage.success("学习材料已更新");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || "更新失败");
|
||||
} finally {
|
||||
savingMaterial.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
timerMinutes,
|
||||
timerSeconds,
|
||||
@@ -550,6 +612,30 @@ onUnmounted(() => {
|
||||
<span class="status-text">状态:{{ statusText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 学习材料 -->
|
||||
<article class="surface-card material-session-card">
|
||||
<div class="material-session-header">
|
||||
<span class="material-session-label">学习材料</span>
|
||||
<el-button
|
||||
v-if="!editingMaterial"
|
||||
text
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="startEditMaterial"
|
||||
>编辑</el-button>
|
||||
</div>
|
||||
<div v-if="materialHtml" class="material-session-content" v-html="materialHtml" />
|
||||
<p v-else class="empty-text">暂未填写材料地址</p>
|
||||
<el-dialog v-model="editingMaterial" title="编辑学习材料" width="560px">
|
||||
<el-input v-model="editMaterialContent" type="textarea" :rows="10" placeholder="支持 Markdown 格式" />
|
||||
<p class="edit-hint">[链接文字](url) 或直接粘贴链接,保存时自动获取标题</p>
|
||||
<template #footer>
|
||||
<el-button @click="editingMaterial = false">取消</el-button>
|
||||
<el-button type="success" :loading="savingMaterial" @click="saveMaterial">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</article>
|
||||
|
||||
<!-- 学习预期 -->
|
||||
<article class="surface-card expectation-card" v-if="expectationSaved">
|
||||
<span class="expectation-label">本次预期</span>
|
||||
@@ -804,6 +890,50 @@ onUnmounted(() => {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.material-session-card {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.material-session-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.material-session-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--green-700);
|
||||
background: #e8f5e9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.material-session-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.material-session-content :deep(a) {
|
||||
color: var(--green-600);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed var(--green-400);
|
||||
}
|
||||
|
||||
.material-session-content :deep(a:hover) {
|
||||
color: var(--green-800);
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
.edit-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dialog-hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
|
||||
+111
-47
@@ -5,6 +5,7 @@ import router from "@/router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { getReviewTaskStatsByTask } from "@/api/review";
|
||||
import { getActiveSession } from "@/api/studySessions";
|
||||
|
||||
import {
|
||||
getPriorityWeights,
|
||||
getTaskApplications,
|
||||
@@ -13,13 +14,12 @@ import {
|
||||
type PriorityWeights,
|
||||
type TaskApplication,
|
||||
} from "@/api/tasks";
|
||||
import { fetchTitles } from "@/utils/fetchTitle";
|
||||
import { getUrlTitle } from "@/utils/fetchTitle";
|
||||
|
||||
interface TaskItem {
|
||||
title: string;
|
||||
description: string;
|
||||
materialURL: string;
|
||||
lastLearningStatus: string;
|
||||
materialUrl: string;
|
||||
priority: number | string;
|
||||
taskNum: string;
|
||||
taskId: number;
|
||||
@@ -37,8 +37,12 @@ interface TaskItem {
|
||||
const tasks = ref<TaskItem[]>([]);
|
||||
const selectedTaskId = ref<number | null>(null);
|
||||
const loading = ref(false);
|
||||
const currentPage = ref(1);
|
||||
const totalTasks = ref(0);
|
||||
const pageSize = 20;
|
||||
const taskApplications = ref<TaskApplication[]>([]);
|
||||
const applicationsLoading = ref(false);
|
||||
const appUrlTitles = ref<Record<number, string>>({});
|
||||
|
||||
const applicationStatusOptions: { label: string; value: TaskApplication["status"] }[] = [
|
||||
{ label: "待应用", value: "TODO" },
|
||||
@@ -50,18 +54,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);
|
||||
@@ -97,6 +116,15 @@ async function loadTaskApplications(taskNum: string) {
|
||||
try {
|
||||
const res = await getTaskApplications(taskNum);
|
||||
taskApplications.value = res?.data || [];
|
||||
const map: Record<number, string> = {};
|
||||
await Promise.all(
|
||||
taskApplications.value
|
||||
.filter((a) => a.resourceUrl)
|
||||
.map(async (a) => {
|
||||
map[a.id] = await getUrlTitle(a.resourceUrl!);
|
||||
})
|
||||
);
|
||||
appUrlTitles.value = map;
|
||||
} catch (error: any) {
|
||||
taskApplications.value = [];
|
||||
ElMessage.error(error?.message || "应用场景加载失败");
|
||||
@@ -122,16 +150,17 @@ const changeApplicationStatus = async (item: TaskApplication) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTasks = async () => {
|
||||
const fetchTasks = async (page = currentPage.value) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const previousSelectedTaskId = selectedTaskId.value;
|
||||
const res = await request.get("/tasks", { pageSize: 100, pageNum: 1 });
|
||||
currentPage.value = page;
|
||||
const res = await request.get("/tasks", { pageSize, pageNum: page });
|
||||
totalTasks.value = res?.data?.total || 0;
|
||||
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,
|
||||
@@ -168,13 +197,17 @@ watch(selectedTaskId, (newId) => {
|
||||
if (task) {
|
||||
loadTaskStats(task.taskNum);
|
||||
loadTaskApplications(task.taskNum);
|
||||
loadUrlTitles();
|
||||
} else {
|
||||
taskApplications.value = [];
|
||||
urlTitles.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
selectedTaskId.value = null;
|
||||
taskApplications.value = [];
|
||||
fetchTasks(page);
|
||||
};
|
||||
|
||||
const startTask = async () => {
|
||||
if (!selectedTask.value) {
|
||||
ElMessage.warning("请先选择一个任务");
|
||||
@@ -315,9 +348,18 @@ onMounted(() => {
|
||||
<span class="task-priority">{{ item.priority }}</span>
|
||||
{{ item.title }}
|
||||
</p>
|
||||
<p class="task-meta">状态:{{ item.lastLearningStatus }}</p>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<el-pagination
|
||||
v-if="totalTasks > pageSize"
|
||||
small
|
||||
layout="prev, pager, next"
|
||||
:total="totalTasks"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
@current-change="handlePageChange"
|
||||
class="task-pagination"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main class="content-zone">
|
||||
@@ -330,18 +372,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>
|
||||
|
||||
@@ -409,7 +445,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<p v-if="item.description">{{ item.description }}</p>
|
||||
<el-link v-if="item.resourceUrl" :href="item.resourceUrl" target="_blank" type="primary">
|
||||
{{ item.resourceUrl }}
|
||||
{{ appUrlTitles[item.id] || item.resourceUrl }}
|
||||
</el-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -569,10 +605,9 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
.task-pagination {
|
||||
margin-top: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content-zone {
|
||||
@@ -597,14 +632,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 {
|
||||
@@ -612,19 +666,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 {
|
||||
@@ -640,12 +704,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);
|
||||
}
|
||||
|
||||
+214
-50
@@ -10,6 +10,7 @@ import {
|
||||
updateTaskApplication,
|
||||
type TaskApplication,
|
||||
} from "@/api/tasks";
|
||||
import { getUrlTitle } from "@/utils/fetchTitle";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -21,7 +22,7 @@ const isUpdateMode = computed(() => route.meta.action === "update");
|
||||
const taskNum = ref("");
|
||||
const taskName = ref("");
|
||||
const taskDescription = ref("");
|
||||
const materialURL = ref("");
|
||||
const materialUrl = ref("");
|
||||
const priority = ref({
|
||||
urgency: 0,
|
||||
importance: 0,
|
||||
@@ -39,6 +40,8 @@ const applicationStatusOptions: { label: string; value: TaskApplication["status"
|
||||
|
||||
const applications = ref<TaskApplication[]>([]);
|
||||
const applicationLoading = ref(false);
|
||||
const appUrlTitles = ref<Record<number, string>>({});
|
||||
const applicationDialogVisible = ref(false);
|
||||
const applicationSaving = ref(false);
|
||||
const editingApplicationId = ref<number | null>(null);
|
||||
const applicationForm = ref<{
|
||||
@@ -53,6 +56,11 @@ const applicationForm = ref<{
|
||||
status: "TODO",
|
||||
});
|
||||
|
||||
const openApplicationDialog = () => {
|
||||
resetApplicationForm();
|
||||
applicationDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const resetApplicationForm = () => {
|
||||
editingApplicationId.value = null;
|
||||
applicationForm.value = {
|
||||
@@ -68,6 +76,16 @@ const loadApplications = async (targetTaskNum: string) => {
|
||||
try {
|
||||
const res = await getTaskApplications(targetTaskNum);
|
||||
applications.value = res?.data || [];
|
||||
// 异步抓取标题
|
||||
const map: Record<number, string> = {};
|
||||
await Promise.all(
|
||||
applications.value
|
||||
.filter((a) => a.resourceUrl)
|
||||
.map(async (a) => {
|
||||
map[a.id] = await getUrlTitle(a.resourceUrl!);
|
||||
})
|
||||
);
|
||||
appUrlTitles.value = map;
|
||||
} catch (error: any) {
|
||||
applications.value = [];
|
||||
ElMessage.error(error?.message || "应用场景加载失败");
|
||||
@@ -100,6 +118,7 @@ const saveApplication = async () => {
|
||||
await createTaskApplication(taskNum.value, payload);
|
||||
ElMessage.success("应用场景已添加");
|
||||
}
|
||||
applicationDialogVisible.value = false;
|
||||
resetApplicationForm();
|
||||
await loadApplications(taskNum.value);
|
||||
} finally {
|
||||
@@ -115,6 +134,7 @@ const editApplication = (item: TaskApplication) => {
|
||||
resourceUrl: item.resourceUrl || "",
|
||||
status: item.status || "TODO",
|
||||
};
|
||||
applicationDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const removeApplication = async (item: TaskApplication) => {
|
||||
@@ -128,12 +148,36 @@ const removeApplication = async (item: TaskApplication) => {
|
||||
}
|
||||
};
|
||||
|
||||
const convertMaterialUrls = async () => {
|
||||
const raw = materialUrl.value || "";
|
||||
if (!raw.trim()) return;
|
||||
const bareUrls = new Set<string>();
|
||||
raw.replace(/https?:\/\/[^\s)\u3001\uFF09\u300D\u300B<>]+/g, (url) => {
|
||||
const clean = url.replace(/[.。,,;;!!??)】」』\]]+$/, "");
|
||||
// 跳过 [text](url) 内的
|
||||
if (!raw.includes(`](${clean})`)) bareUrls.add(clean);
|
||||
return url;
|
||||
});
|
||||
if (bareUrls.size === 0) return;
|
||||
const titles: Record<string, string> = {};
|
||||
const results = await Promise.all([...bareUrls].map(async (u) => ({ u, t: await getUrlTitle(u) })));
|
||||
results.forEach(({ u, t }) => { titles[u] = t; });
|
||||
let result = raw;
|
||||
for (const u of bareUrls) {
|
||||
if (titles[u] && titles[u] !== u) {
|
||||
result = result.replace(u, `[${titles[u].replace(/]/g, "\\]")}](${u})`);
|
||||
}
|
||||
}
|
||||
materialUrl.value = result;
|
||||
};
|
||||
|
||||
const createTask = () => {
|
||||
convertMaterialUrls().then(() => {
|
||||
request
|
||||
.post("/tasks", {
|
||||
taskName: taskName.value,
|
||||
taskDescription: taskDescription.value,
|
||||
materialURL: materialURL.value,
|
||||
materialUrl: materialUrl.value,
|
||||
...priority.value,
|
||||
})
|
||||
.then((result) => {
|
||||
@@ -144,6 +188,7 @@ const createTask = () => {
|
||||
ElMessage.error("任务创建失败:" + result.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const loadTask = () => {
|
||||
@@ -154,7 +199,7 @@ const loadTask = () => {
|
||||
taskNum.value = data.taskNum || "";
|
||||
taskName.value = data.taskName;
|
||||
taskDescription.value = data.taskDescription;
|
||||
materialURL.value = data.materialURL || data.materialUrl || "";
|
||||
materialUrl.value = data.materialUrl || "";
|
||||
priority.value = {
|
||||
urgency: data.urgency,
|
||||
importance: data.importance,
|
||||
@@ -173,12 +218,13 @@ const loadTask = () => {
|
||||
};
|
||||
|
||||
const updateTask = () => {
|
||||
convertMaterialUrls().then(() => {
|
||||
request
|
||||
.put("/tasks/" + taskId, {
|
||||
id: taskId,
|
||||
taskName: taskName.value,
|
||||
taskDescription: taskDescription.value,
|
||||
materialURL: materialURL.value,
|
||||
materialUrl: materialUrl.value,
|
||||
...priority.value,
|
||||
})
|
||||
.then((result) => {
|
||||
@@ -189,6 +235,7 @@ const updateTask = () => {
|
||||
ElMessage.error("任务更新失败:" + result.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const cancelTask = () => {
|
||||
@@ -225,6 +272,61 @@ onMounted(() => {
|
||||
onUnmounted(() => window.removeEventListener("resize", updateWidth));
|
||||
|
||||
const isMobile = computed(() => screenWidth.value <= 900);
|
||||
|
||||
// 学习材料 Markdown 预览
|
||||
const showPreview = ref(false);
|
||||
const materialPreview = ref("");
|
||||
const previewLoading = ref(false);
|
||||
|
||||
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const escapeAttr = (s: string) => s.replace(/"/g, """).replace(/&/g, "&");
|
||||
|
||||
function renderMarkdown(raw: string, urlTitles: Record<string, string> = {}): string {
|
||||
if (!raw.trim()) return "";
|
||||
let html = raw;
|
||||
// 1) [text](url) → <a> 占位,防止后续裸 URL 匹配进属性里
|
||||
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}__`;
|
||||
});
|
||||
// 2) 裸 URL → <a> 占位
|
||||
html = html.replace(/https?:\/\/[^\s)\u3001\uFF09\u300D\u300B<>]+/g, (url) => {
|
||||
const clean = url.replace(/[.。,,;;!!??)】」』\]]+$/, "");
|
||||
const label = urlTitles[clean] || url;
|
||||
const tag = `<a href="${escapeAttr(clean)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`;
|
||||
links.push(tag);
|
||||
return `__LINK_${links.length - 1}__`;
|
||||
});
|
||||
// 3) 换行
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
// 4) 还原占位
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
html = html.replace(`__LINK_${i}__`, links[i]);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
async function previewMaterial() {
|
||||
showPreview.value = true;
|
||||
const raw = materialUrl.value || "";
|
||||
if (!raw.trim()) { materialPreview.value = ""; return; }
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const titles: Record<string, string> = {};
|
||||
const bareUrls = new Set<string>();
|
||||
raw.replace(/https?:\/\/[^\s)\u3001\uFF09\u300D\u300B<>]+/g, (url) => {
|
||||
bareUrls.add(url.replace(/[.。,,;;!!??)】」』\]]+$/, ""));
|
||||
return url;
|
||||
});
|
||||
const results = await Promise.all([...bareUrls].map(async (u) => ({ u, t: await getUrlTitle(u) })));
|
||||
results.forEach(({ u, t }) => { titles[u] = t; });
|
||||
materialPreview.value = renderMarkdown(raw, titles);
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -238,8 +340,35 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
<el-form-item label="任务描述">
|
||||
<el-input v-model="taskDescription" type="textarea" :rows="5" placeholder="请输入任务描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学习材料地址">
|
||||
<el-input v-model="materialURL" type="textarea" :rows="3" placeholder="每行输入一个链接 例如:https://vuejs.org/guide https://github.com/vuejs/core" />
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="material-label">
|
||||
<span>学习材料地址</span>
|
||||
<el-button
|
||||
v-if="!showPreview"
|
||||
text
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="previewMaterial()"
|
||||
>预览</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
text
|
||||
size="small"
|
||||
@click="showPreview = false"
|
||||
>编辑</el-button>
|
||||
<span class="material-hint" v-if="!showPreview">[链接文字](url) 或直接粘贴链接</span>
|
||||
<span class="material-hint" v-else>链接会自动获取标题展示</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-input
|
||||
v-if="!showPreview"
|
||||
v-model="materialUrl"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="支持 Markdown 格式 例如: 参考 [Vue.js 文档](https://vuejs.org/guide) 源码在 https://github.com/vuejs/core"
|
||||
/>
|
||||
<div v-else class="material-preview" v-html="materialPreview" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
@@ -279,7 +408,10 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
</div>
|
||||
|
||||
<div class="group" v-if="isUpdateMode" v-loading="applicationLoading">
|
||||
<div class="application-header">
|
||||
<h3>应用场景</h3>
|
||||
<el-button type="primary" @click="openApplicationDialog()">添加应用场景</el-button>
|
||||
</div>
|
||||
<div class="application-list" v-if="applications.length > 0">
|
||||
<div class="application-item" v-for="item in applications" :key="item.id">
|
||||
<div class="application-main">
|
||||
@@ -290,7 +422,7 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
</div>
|
||||
<p v-if="item.description">{{ item.description }}</p>
|
||||
<el-link v-if="item.resourceUrl" :href="item.resourceUrl" target="_blank" type="primary">
|
||||
{{ item.resourceUrl }}
|
||||
{{ appUrlTitles[item.id] || item.resourceUrl }}
|
||||
</el-link>
|
||||
<div class="application-actions">
|
||||
<el-button text type="primary" size="small" @click="editApplication(item)">编辑</el-button>
|
||||
@@ -299,32 +431,32 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty-text">暂未设置应用场景</p>
|
||||
</div>
|
||||
|
||||
<div class="application-form">
|
||||
<el-input v-model="applicationForm.title" placeholder="应用项目" />
|
||||
<el-input
|
||||
v-model="applicationForm.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="应用描述"
|
||||
/>
|
||||
<el-input v-model="applicationForm.resourceUrl" placeholder="相关链接" />
|
||||
<div class="application-form-row">
|
||||
<el-dialog v-model="applicationDialogVisible" :title="editingApplicationId ? '编辑应用场景' : '添加应用场景'" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="应用项目" required>
|
||||
<el-input v-model="applicationForm.title" placeholder="例如:用Vue3重构个人博客" />
|
||||
</el-form-item>
|
||||
<el-form-item label="应用描述">
|
||||
<el-input v-model="applicationForm.description" type="textarea" :rows="4" placeholder="描述如何应用所学内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="相关链接">
|
||||
<el-input v-model="applicationForm.resourceUrl" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="applicationForm.status" placeholder="状态">
|
||||
<el-option
|
||||
v-for="option in applicationStatusOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
<el-option v-for="option in applicationStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="applicationDialogVisible = false">取消</el-button>
|
||||
<el-button type="success" :loading="applicationSaving" @click="saveApplication">
|
||||
{{ editingApplicationId ? "更新" : "添加" }}
|
||||
{{ editingApplicationId ? '更新' : '添加' }}
|
||||
</el-button>
|
||||
<el-button v-if="editingApplicationId" @click="resetApplicationForm">取消</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button type="success" size="large" @click="submitTask">{{ buttonName }}</el-button>
|
||||
@@ -354,6 +486,49 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
color: var(--green-900);
|
||||
}
|
||||
|
||||
.material-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
|
||||
.material-label .material-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.material-preview {
|
||||
min-height: 120px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: #fafdfb;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.material-preview :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-preview :deep(a:hover) {
|
||||
color: var(--green-800);
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
.material-preview :deep(a)::after {
|
||||
content: " ↗";
|
||||
font-size: 11px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.priority-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -364,6 +539,17 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.application-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.application-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.application-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -405,20 +591,6 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.application-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.application-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 220px) auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-secondary);
|
||||
@@ -450,13 +622,5 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.application-form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.application-form-row .el-button {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+20
-30
@@ -1,36 +1,26 @@
|
||||
/**
|
||||
* 网页标题抓取工具。
|
||||
* 调用 lpt-ai 的 /fetch-title 接口获取 URL 对应的 <title> 标签内容。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 内存缓存避免同页面重复请求
|
||||
* - 失败时回退到 URL 本身
|
||||
* - 仅在 lpt-ai 运行时可用
|
||||
* URL 标题缓存 + 按需抓取,调用后端 /utils/fetch-title 代理
|
||||
*/
|
||||
|
||||
const LPT_AI_URL = "http://localhost:5199";
|
||||
import request from "@/utils/request";
|
||||
|
||||
const titleCache: Record<string, string> = {};
|
||||
const cache = new Map<string, string>();
|
||||
const pending = new Map<string, Promise<string>>();
|
||||
|
||||
export async function fetchTitle(url: string): Promise<string> {
|
||||
if (titleCache[url]) return titleCache[url];
|
||||
try {
|
||||
const res = await fetch(`${LPT_AI_URL}/fetch-title`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
if (!res.ok) return url;
|
||||
const data = await res.json();
|
||||
titleCache[url] = data.title || url;
|
||||
return titleCache[url];
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量获取标题 */
|
||||
export async function fetchTitles(urls: string[]): Promise<string[]> {
|
||||
return Promise.all(urls.map((url) => fetchTitle(url)));
|
||||
export async function getUrlTitle(url: string): Promise<string> {
|
||||
if (cache.has(url)) return cache.get(url)!;
|
||||
if (pending.has(url)) return pending.get(url)!;
|
||||
|
||||
const promise = request
|
||||
.get("/utils/fetch-title", { url })
|
||||
.then((res: any) => {
|
||||
const title = res?.data?.title || url;
|
||||
cache.set(url, title);
|
||||
return title;
|
||||
})
|
||||
.catch(() => url)
|
||||
.finally(() => pending.delete(url));
|
||||
|
||||
pending.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
@@ -12,14 +12,21 @@ const axiosInstance = axios.create({
|
||||
|
||||
const handleError = (error: any) => {
|
||||
|
||||
// 401 未授权:token 过期或未登录,直接跳转登录页
|
||||
// 401 未授权(HTTP 层):token 过期或未登录
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem("isLoggedIn");
|
||||
router.push("/login");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 业务异常由 validateResponse 负责提示,这里透传,避免被网络错误文案覆盖。
|
||||
// 401 未授权(业务层):后端 GlobalExceptionHandler 返回 code=401
|
||||
if (error && typeof error === "object" && error.code === 401) {
|
||||
localStorage.removeItem("isLoggedIn");
|
||||
router.push("/login");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 其他业务异常(如 code !== 200)透传,由调用方自行提示
|
||||
if (error && typeof error === "object" && "code" in error && !error.response) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user