feat(学习材料):多 URL 支持 + 网页标题自动抓取

TaskForm.vue:
- 学习材料输入从单行改为 textarea,每行一个链接

Study.vue:
- 改为迭代多 URL 展示,每行显示网页标题(通过 lpt-ai
  /fetch-title 获取)或回退到 URL 本身
- 选中不同任务时自动刷新标题

新增 src/utils/fetchTitle.ts:
- fetchTitle(url) / fetchTitles(urls) 工具函数
- 内存缓存避免重复请求
- 失败时回退到 URL,不阻塞页面

后端:
- V20260704_1 迁移:material_url varchar(255) → TEXT
This commit is contained in:
2026-07-04 22:37:01 +08:00
parent 5f7a5a55a4
commit a63942d105
3 changed files with 74 additions and 9 deletions
+36 -7
View File
@@ -12,6 +12,7 @@ import {
type PriorityWeights,
type TaskApplication,
} from "@/api/tasks";
import { fetchTitles } from "@/utils/fetchTitle";
interface TaskItem {
title: string;
@@ -48,6 +49,18 @@ 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 formatEffectiveTime = (seconds: number): string => {
if (!seconds || seconds <= 0) return "0分钟";
const hours = Math.floor(seconds / 3600);
@@ -154,8 +167,10 @@ watch(selectedTaskId, (newId) => {
if (task) {
loadTaskStats(task.taskNum);
loadTaskApplications(task.taskNum);
loadUrlTitles();
} else {
taskApplications.value = [];
urlTitles.value = [];
}
});
@@ -298,14 +313,13 @@ onMounted(() => {
<div class="detail-block">
<p class="label">学习材料</p>
<el-link
v-if="selectedTask.materialURL"
:href="selectedTask.materialURL"
target="_blank"
type="primary"
>
{{ selectedTask.materialURL }}
<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>
<p v-else class="empty-text">暂未填写材料地址</p>
</div>
@@ -559,6 +573,21 @@ onMounted(() => {
color: var(--text-secondary);
}
.material-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.material-item {
padding: 4px 0;
word-break: break-all;
}
.material-item .el-link {
font-size: 13px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
+1 -1
View File
@@ -239,7 +239,7 @@ const isMobile = computed(() => screenWidth.value <= 900);
<el-input v-model="taskDescription" type="textarea" :rows="5" placeholder="请输入任务描述" />
</el-form-item>
<el-form-item label="学习材料地址">
<el-input v-model="materialURL" placeholder="例如:https://..." />
<el-input v-model="materialURL" type="textarea" :rows="3" placeholder="每行输入一个链接&#10;例如:https://vuejs.org/guide&#10;https://github.com/vuejs/core" />
</el-form-item>
</div>
+36
View File
@@ -0,0 +1,36 @@
/**
* 网页标题抓取工具。
* 调用 lpt-ai 的 /fetch-title 接口获取 URL 对应的 <title> 标签内容。
*
* 设计要点:
* - 内存缓存避免同页面重复请求
* - 失败时回退到 URL 本身
* - 仅在 lpt-ai 运行时可用
*/
const LPT_AI_URL = "http://localhost:5199";
const titleCache: Record<string, 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)));
}