Files
lpt-fe/src/components/TaskForm.vue
T

632 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import request from "@/utils/request";
import { ElMessage } from "element-plus";
import { useRoute, useRouter } from "vue-router";
import {
createTaskApplication,
deleteTaskApplication,
getTaskApplications,
updateTaskApplication,
type TaskApplication,
} from "@/api/tasks";
import { getUrlTitle } from "@/utils/fetchTitle";
import { renderMarkdown } from "@/utils/markdown";
import { applicationStatusOptions } from "@/utils/taskApplication";
const router = useRouter();
const route = useRoute();
const taskId = route.params.taskId ? Number(route.params.taskId) : null;
const buttonName = route.meta.buttonText as string;
const isUpdateMode = computed(() => route.meta.action === "update");
const taskNum = ref("");
const taskName = ref("");
const taskDescription = ref("");
const materialUrl = ref("");
const priority = ref({
urgency: 0,
importance: 0,
contentDifficulty: 0,
futureValue: 0,
subjectivePriority: 0,
});
const priorityOptions = [0, 1, 2, 3, 4, 5];
const applications = ref<TaskApplication[]>([]);
const applicationLoading = ref(false);
const appUrlTitles = ref<Record<number, string>>({});
const applicationDialogVisible = ref(false);
const applicationSaving = ref(false);
const deletingApplicationId = ref<number | null>(null);
const editingApplicationId = ref<number | null>(null);
const pageLoading = ref(false);
const submitting = ref(false);
const previewLoading = ref(false);
const applicationForm = ref<{
title: string;
description: string;
resourceUrl: string;
status: TaskApplication["status"];
}>({
title: "",
description: "",
resourceUrl: "",
status: "TODO",
});
const openApplicationDialog = () => {
resetApplicationForm();
applicationDialogVisible.value = true;
};
const resetApplicationForm = () => {
editingApplicationId.value = null;
applicationForm.value = {
title: "",
description: "",
resourceUrl: "",
status: "TODO",
};
};
const loadApplications = async (targetTaskNum: string) => {
applicationLoading.value = true;
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 || "应用场景加载失败");
} finally {
applicationLoading.value = false;
}
};
const saveApplication = async () => {
if (!taskNum.value) {
ElMessage.warning("任务编号未加载,暂不能维护应用场景");
return;
}
if (!applicationForm.value.title.trim()) {
ElMessage.warning("应用项目标题不能为空");
return;
}
applicationSaving.value = true;
try {
const payload = {
title: applicationForm.value.title,
description: applicationForm.value.description,
resourceUrl: applicationForm.value.resourceUrl,
status: applicationForm.value.status,
};
if (editingApplicationId.value) {
await updateTaskApplication(editingApplicationId.value, payload);
ElMessage.success("应用场景已更新");
} else {
await createTaskApplication(taskNum.value, payload);
ElMessage.success("应用场景已添加");
}
applicationDialogVisible.value = false;
resetApplicationForm();
await loadApplications(taskNum.value);
} finally {
applicationSaving.value = false;
}
};
const editApplication = (item: TaskApplication) => {
editingApplicationId.value = item.id;
applicationForm.value = {
title: item.title || "",
description: item.description || "",
resourceUrl: item.resourceUrl || "",
status: item.status || "TODO",
};
applicationDialogVisible.value = true;
};
const removeApplication = async (item: TaskApplication) => {
if (deletingApplicationId.value !== null) return;
deletingApplicationId.value = item.id;
try {
await deleteTaskApplication(item.id);
ElMessage.success("应用场景已删除");
if (editingApplicationId.value === item.id) {
resetApplicationForm();
}
if (taskNum.value) {
await loadApplications(taskNum.value);
}
} finally {
deletingApplicationId.value = null;
}
};
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 = async () => {
if (submitting.value) return;
submitting.value = true;
try {
await convertMaterialUrls();
const result = await request.post("/tasks", {
taskName: taskName.value,
taskDescription: taskDescription.value,
materialUrl: materialUrl.value,
...priority.value,
});
if (result.code === 200) {
ElMessage.success("创建任务成功");
await router.push({ name: "study" });
} else {
ElMessage.error("任务创建失败:" + result.message);
}
} finally {
submitting.value = false;
}
};
const loadTask = async () => {
if (taskId === null) return;
pageLoading.value = true;
try {
const result = await request.get(`/tasks/${taskId}`, {});
if (result.code === 200) {
const data = result.data;
taskNum.value = data.taskNum || "";
taskName.value = data.taskName;
taskDescription.value = data.taskDescription;
materialUrl.value = data.materialUrl || "";
priority.value = {
urgency: data.urgency,
importance: data.importance,
contentDifficulty: data.contentDifficulty,
futureValue: data.futureValue,
subjectivePriority: data.subjectivePriority,
};
if (taskNum.value) {
loadApplications(taskNum.value);
}
} else {
ElMessage.error(`任务加载失败:${result.message}`);
}
} catch (error: any) {
ElMessage.error(error?.message || "任务加载失败,请重试");
} finally {
pageLoading.value = false;
}
};
const updateTask = async () => {
if (submitting.value) return;
submitting.value = true;
try {
await convertMaterialUrls();
const result = await request.put("/tasks/" + taskId, {
id: taskId,
taskName: taskName.value,
taskDescription: taskDescription.value,
materialUrl: materialUrl.value,
...priority.value,
});
if (result.code === 200) {
ElMessage.success("更新任务成功");
await router.push({ name: "study" });
} else {
ElMessage.error("任务更新失败:" + result.message);
}
} finally {
submitting.value = false;
}
};
const cancelTask = () => {
ElMessage.info("已取消操作");
router.push({ name: "study" });
};
const submitTask = async () => {
if (submitting.value) return;
switch (route.name) {
case "add-task":
await createTask();
break;
case "update-task":
await updateTask();
break;
default:
ElMessage.error("该功能暂不可用,请刷新后重试");
}
};
const screenWidth = ref(window.innerWidth);
const updateWidth = () => {
screenWidth.value = window.innerWidth;
};
onMounted(() => {
window.addEventListener("resize", updateWidth);
if (route.meta.action === "update" && taskId !== null) {
loadTask();
}
});
onUnmounted(() => window.removeEventListener("resize", updateWidth));
const isMobile = computed(() => screenWidth.value <= 900);
// 学习材料 Markdown 预览
const showPreview = ref(false);
const materialPreview = ref("");
async function previewMaterial() {
if (previewLoading.value) return;
previewLoading.value = true;
try {
const raw = materialUrl.value || "";
if (!raw.trim()) {
materialPreview.value = "";
showPreview.value = true;
return;
}
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);
showPreview.value = true;
} finally {
previewLoading.value = false;
}
}
defineExpose({
priorityOptions,
priority,
taskNum,
taskName,
taskDescription,
materialUrl,
isUpdateMode,
loadTask,
createTask,
updateTask,
submitTask,
convertMaterialUrls,
});
</script>
<template>
<section class="task-form-page" v-loading="pageLoading">
<el-form label-position="top" class="surface-card form-shell">
<div class="group">
<h3>基础信息</h3>
<el-form-item label="任务名称">
<el-input v-model="taskName" placeholder="例如:Vue3 组件通信实践" />
</el-form-item>
<el-form-item label="任务描述">
<el-input v-model="taskDescription" type="textarea" :rows="5" placeholder="请输入任务描述" />
</el-form-item>
<el-form-item>
<template #label>
<div class="material-label">
<span>学习材料地址</span>
<el-button
v-if="!showPreview"
text
size="small"
type="primary"
:loading="previewLoading"
@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 格式&#10;例如:&#10;参考 [Vue.js 文档](https://vuejs.org/guide)&#10;源码在 https://github.com/vuejs/core"
/>
<div v-else class="material-preview" v-html="materialPreview" />
</el-form-item>
</div>
<div class="group">
<h3>优先级维度</h3>
<div class="priority-grid" :class="{ mobile: isMobile }">
<el-form-item label="急迫性">
<el-select v-model="priority.urgency">
<el-option v-for="item in priorityOptions" :key="'u' + item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="重要性">
<el-select v-model="priority.importance">
<el-option v-for="item in priorityOptions" :key="'i' + item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="内容难度">
<el-select v-model="priority.contentDifficulty">
<el-option v-for="item in priorityOptions" :key="'d' + item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="未来价值">
<el-select v-model="priority.futureValue">
<el-option v-for="item in priorityOptions" :key="'f' + item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="主观优先级">
<el-select v-model="priority.subjectivePriority">
<el-option v-for="item in priorityOptions" :key="'s' + item" :label="item" :value="item" />
</el-select>
</el-form-item>
</div>
</div>
<div class="group" v-if="isUpdateMode" v-loading="applicationLoading">
<div class="application-header">
<h3>应用场景</h3>
<el-button type="success" @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">
<strong>{{ item.title }}</strong>
<el-tag size="small" effect="plain">
{{ applicationStatusOptions.find(option => option.value === item.status)?.label || item.status }}
</el-tag>
</div>
<p v-if="item.description">{{ item.description }}</p>
<el-link v-if="item.resourceUrl" :href="item.resourceUrl" target="_blank" type="primary">
{{ appUrlTitles[item.id] || item.resourceUrl }}
</el-link>
<div class="application-actions">
<el-button text type="primary" size="small" @click="editApplication(item)">编辑</el-button>
<el-button
text
type="danger"
size="small"
:loading="deletingApplicationId === item.id"
:disabled="deletingApplicationId !== null"
@click="removeApplication(item)"
>删除</el-button>
</div>
</div>
</div>
<p v-else class="empty-text">暂未设置应用场景</p>
</div>
<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-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="applicationDialogVisible = false">取消</el-button>
<el-button type="success" :loading="applicationSaving" @click="saveApplication">
{{ editingApplicationId ? '更新' : '添加' }}
</el-button>
</template>
</el-dialog>
<div class="action-row">
<el-button type="success" size="large" :loading="submitting" @click="submitTask">{{ buttonName }}</el-button>
<el-button size="large" @click="cancelTask">取消</el-button>
</div>
</el-form>
</section>
</template>
<style scoped>
.task-form-page {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-shell {
padding: 22px;
}
.group + .group {
margin-top: 12px;
}
.group h3 {
margin: 0 0 12px;
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));
gap: 0 14px;
}
.priority-grid.mobile {
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;
gap: 10px;
margin-bottom: 14px;
}
.application-item {
padding: 12px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: #fbfefc;
}
.application-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.application-main strong {
min-width: 0;
color: var(--text-primary);
word-break: break-word;
}
.application-item p {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 13px;
word-break: break-word;
}
.application-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 8px;
}
.empty-text {
margin: 0 0 12px;
color: var(--text-secondary);
font-size: 13px;
}
.action-row {
margin-top: 8px;
display: flex;
gap: 10px;
}
@media (max-width: 768px) {
.form-shell {
padding: 16px;
}
.application-main {
align-items: flex-start;
flex-direction: column;
}
}
</style>