Files
lpt-fe/src/components/TaskForm.vue
T
developer 2315c0d08f feat: TaskForm 应用场景表单弹窗化
- 应用场景表单从内联改为 el-dialog 弹窗
- 新增「添加应用场景」按钮,编辑也走弹窗
- 表单 label-position=top,布局更清晰
- 移除旧的 application-form 行内布局样式
- 新增 application-header 弹性布局
- 弹窗在保存/取消后自动关闭
2026-07-12 13:50:23 +08:00

627 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";
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 applicationStatusOptions: { label: string; value: TaskApplication["status"] }[] = [
{ label: "待应用", value: "TODO" },
{ label: "进行中", value: "DOING" },
{ label: "已完成", value: "DONE" },
];
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<{
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) => {
await deleteTaskApplication(item.id);
ElMessage.success("应用场景已删除");
if (editingApplicationId.value === item.id) {
resetApplicationForm();
}
if (taskNum.value) {
await loadApplications(taskNum.value);
}
};
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,
...priority.value,
})
.then((result) => {
if (result.code === 200) {
ElMessage.success("创建任务成功");
router.push({ name: "study" });
} else {
ElMessage.error("任务创建失败:" + result.message);
}
});
});
};
const loadTask = () => {
if (taskId !== null) {
request.get(`/tasks/${taskId}`, {}).then((result) => {
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}`);
}
});
}
};
const updateTask = () => {
convertMaterialUrls().then(() => {
request
.put("/tasks/" + taskId, {
id: taskId,
taskName: taskName.value,
taskDescription: taskDescription.value,
materialUrl: materialUrl.value,
...priority.value,
})
.then((result) => {
if (result.code === 200) {
ElMessage.success("更新任务成功");
router.push({ name: "study" });
} else {
ElMessage.error("任务更新失败:" + result.message);
}
});
});
};
const cancelTask = () => {
ElMessage.info("已取消操作");
router.push({ name: "study" });
};
const submitTask = () => {
switch (route.name) {
case "add-task":
createTask();
break;
case "update-task":
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("");
const previewLoading = ref(false);
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escapeAttr = (s: string) => s.replace(/"/g, "&quot;").replace(/&/g, "&amp;");
function renderMarkdown(raw: string, urlTitles: Record<string, string> = {}): string {
if (!raw.trim()) return "";
let html = raw;
// 1) [text](url) → &lt;a&gt; 占位,防止后续裸 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 → &lt;a&gt; 占位
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>
<section class="task-form-page">
<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"
@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="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">
<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" @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" @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;
}
.action-row {
flex-direction: column;
}
.action-row .el-button {
width: 100%;
margin: 0;
}
.application-main {
align-items: flex-start;
flex-direction: column;
}
}
</style>