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:
@@ -12,6 +12,7 @@ import {
|
|||||||
type PriorityWeights,
|
type PriorityWeights,
|
||||||
type TaskApplication,
|
type TaskApplication,
|
||||||
} from "@/api/tasks";
|
} from "@/api/tasks";
|
||||||
|
import { fetchTitles } from "@/utils/fetchTitle";
|
||||||
|
|
||||||
interface TaskItem {
|
interface TaskItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,6 +49,18 @@ const selectedTask = computed(() =>
|
|||||||
tasks.value.find((item) => item.taskId === selectedTaskId.value) || null
|
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 => {
|
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);
|
||||||
@@ -154,8 +167,10 @@ watch(selectedTaskId, (newId) => {
|
|||||||
if (task) {
|
if (task) {
|
||||||
loadTaskStats(task.taskNum);
|
loadTaskStats(task.taskNum);
|
||||||
loadTaskApplications(task.taskNum);
|
loadTaskApplications(task.taskNum);
|
||||||
|
loadUrlTitles();
|
||||||
} else {
|
} else {
|
||||||
taskApplications.value = [];
|
taskApplications.value = [];
|
||||||
|
urlTitles.value = [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,14 +313,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="detail-block">
|
<div class="detail-block">
|
||||||
<p class="label">学习材料</p>
|
<p class="label">学习材料</p>
|
||||||
<el-link
|
<div v-if="urlList.length > 0" class="material-list">
|
||||||
v-if="selectedTask.materialURL"
|
<div v-for="(url, i) in urlList" :key="i" class="material-item">
|
||||||
:href="selectedTask.materialURL"
|
<el-link :href="url" target="_blank" type="success">
|
||||||
target="_blank"
|
{{ urlTitles[i] || url }}
|
||||||
type="primary"
|
</el-link>
|
||||||
>
|
</div>
|
||||||
{{ selectedTask.materialURL }}
|
</div>
|
||||||
</el-link>
|
|
||||||
<p v-else class="empty-text">暂未填写材料地址</p>
|
<p v-else class="empty-text">暂未填写材料地址</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -559,6 +573,21 @@ onMounted(() => {
|
|||||||
color: var(--text-secondary);
|
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 {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ const isMobile = computed(() => screenWidth.value <= 900);
|
|||||||
<el-input v-model="taskDescription" type="textarea" :rows="5" placeholder="请输入任务描述" />
|
<el-input v-model="taskDescription" type="textarea" :rows="5" placeholder="请输入任务描述" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="学习材料地址">
|
<el-form-item label="学习材料地址">
|
||||||
<el-input v-model="materialURL" placeholder="例如:https://..." />
|
<el-input v-model="materialURL" type="textarea" :rows="3" placeholder="每行输入一个链接 例如:https://vuejs.org/guide https://github.com/vuejs/core" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user