feat: 优化分类选择器与笔记移动功能
This commit is contained in:
@@ -35,6 +35,8 @@ const saveStatus = ref('');
|
||||
let saveTimeout = null;
|
||||
let lastSavedContent = ref('');
|
||||
let isSaving = ref(false);
|
||||
// 维护当前最新的笔记数据
|
||||
const currentData = ref({ ...props.editData });
|
||||
|
||||
const initVditor = () => {
|
||||
if (vditor.value) {
|
||||
@@ -49,12 +51,12 @@ const initVditor = () => {
|
||||
},
|
||||
after: () => {
|
||||
isInitialized.value = true;
|
||||
if (props.editData && props.editData.content) {
|
||||
vditor.value.setValue(props.editData.content);
|
||||
lastSavedContent.value = props.editData.content;
|
||||
if (currentData.value && currentData.value.content) {
|
||||
vditor.value.setValue(currentData.value.content);
|
||||
lastSavedContent.value = currentData.value.content;
|
||||
}
|
||||
if (props.editData && props.editData.id) {
|
||||
currentId.value = props.editData.id;
|
||||
if (currentData.value && currentData.value.id) {
|
||||
currentId.value = currentData.value.id;
|
||||
}
|
||||
vditor.value.focus();
|
||||
},
|
||||
@@ -101,31 +103,42 @@ const save = async (value) => {
|
||||
try {
|
||||
saveStatus.value = '正在保存...';
|
||||
|
||||
// 确保groupingId不会丢失:优先使用currentData中的值
|
||||
const groupingId = currentData.value.groupingId || props.editData.groupingId;
|
||||
|
||||
// 将ID转为字符串以避免JavaScript精度丢失
|
||||
const idString = currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null);
|
||||
const groupingIdString = groupingId ? String(groupingId) : null;
|
||||
|
||||
const payload = {
|
||||
id: currentId.value || props.editData.id || null,
|
||||
id: idString,
|
||||
content: content,
|
||||
title: props.editData.title,
|
||||
groupingId: props.editData.groupingId,
|
||||
fileName: props.editData.fileName,
|
||||
isPrivate: props.editData.isPrivate
|
||||
title: currentData.value.title || props.editData.title,
|
||||
groupingId: groupingIdString,
|
||||
fileName: currentData.value.fileName || props.editData.fileName,
|
||||
isPrivate: currentData.value.isPrivate !== undefined ? currentData.value.isPrivate : props.editData.isPrivate
|
||||
};
|
||||
|
||||
|
||||
|
||||
const response = await updateMarkdown(payload);
|
||||
|
||||
if (response && response.id) {
|
||||
currentId.value = response.id;
|
||||
lastSavedContent.value = content;
|
||||
|
||||
// 使用后端返回的数据,但确保groupingId不会丢失
|
||||
// 注意:后端返回的ID是字符串,保持字符串格式避免精度丢失
|
||||
const updatedFile = {
|
||||
...props.editData,
|
||||
id: response.id,
|
||||
...response,
|
||||
content: content,
|
||||
groupingId: response.groupingId,
|
||||
groupingName: response.groupingName,
|
||||
title: response.title,
|
||||
isPrivate: response.isPrivate
|
||||
// 如果后端返回的groupingId为空,使用原来的值(保持字符串格式)
|
||||
groupingId: response.groupingId || groupingIdString,
|
||||
groupingName: response.groupingName || currentData.value.groupingName
|
||||
};
|
||||
|
||||
// 更新currentData为最新数据
|
||||
currentData.value = updatedFile;
|
||||
emit('update:editData', updatedFile);
|
||||
saveStatus.value = '已保存';
|
||||
}
|
||||
@@ -143,12 +156,17 @@ const handleBack = async () => {
|
||||
await save(content);
|
||||
}
|
||||
|
||||
// 确保groupingId不会丢失(保持字符串格式)
|
||||
const groupingId = currentData.value.groupingId || props.editData.groupingId;
|
||||
const groupingName = currentData.value.groupingName || props.editData.groupingName;
|
||||
|
||||
const returnData = {
|
||||
...currentData.value,
|
||||
...props.editData,
|
||||
id: currentId.value || props.editData.id,
|
||||
id: currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null),
|
||||
content: content,
|
||||
groupingId: props.editData.groupingId,
|
||||
groupingName: props.editData.groupingName
|
||||
groupingId: groupingId ? String(groupingId) : null,
|
||||
groupingName: groupingName
|
||||
};
|
||||
|
||||
emit('back', returnData);
|
||||
@@ -170,6 +188,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
watch(() => props.editData, (newVal, oldVal) => {
|
||||
if (vditor.value && isInitialized.value && newVal && newVal.id !== oldVal?.id) {
|
||||
// 更新currentData为最新的props数据
|
||||
currentData.value = { ...newVal };
|
||||
vditor.value.setValue(newVal.content || '');
|
||||
lastSavedContent.value = newVal.content || '';
|
||||
currentId.value = newVal.id;
|
||||
|
||||
@@ -2,23 +2,44 @@
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="新建分类"
|
||||
width="400px"
|
||||
width="450px"
|
||||
@close="handleClose"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="90px">
|
||||
<el-form-item label="父级分类">
|
||||
<el-cascader
|
||||
v-model="form.parentId"
|
||||
:options="categoryOptions"
|
||||
:props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'grouping' }"
|
||||
:props="cascaderProps"
|
||||
clearable
|
||||
placeholder="不选则为一级分类"
|
||||
filterable
|
||||
placeholder="不选则创建为一级分类"
|
||||
style="width: 100%;"
|
||||
></el-cascader>
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="category-option">
|
||||
<el-icon class="category-icon">
|
||||
<Folder v-if="data.children && data.children.length > 0" />
|
||||
<Folder-Opened v-else-if="node.isLeaf" />
|
||||
<Folder v-else />
|
||||
</el-icon>
|
||||
<span class="category-name">{{ data.grouping }}</span>
|
||||
<span v-if="data.children && data.children.length > 0" class="category-count">
|
||||
({{ data.children.length }})
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-cascader>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类名称" prop="name">
|
||||
<el-input v-model="form.name" autocomplete="off"></el-input>
|
||||
<el-input
|
||||
v-model="form.name"
|
||||
autocomplete="off"
|
||||
placeholder="请输入新分类名称"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -31,6 +52,7 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Folder, FolderOpened } from '@element-plus/icons-vue';
|
||||
import { addGroupings } from '@/api/CommonApi.js';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -56,6 +78,15 @@ const rules = ref({
|
||||
name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
// 级联选择器配置
|
||||
const cascaderProps = {
|
||||
checkStrictly: true,
|
||||
emitPath: false,
|
||||
value: 'id',
|
||||
label: 'grouping',
|
||||
children: 'children'
|
||||
};
|
||||
|
||||
// 当对话框关闭时,重置表单
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal && formRef.value) {
|
||||
@@ -88,3 +119,25 @@ const handleSubmit = async () => {
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
color: #409eff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,23 +2,43 @@
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="新建笔记"
|
||||
width="400px"
|
||||
width="450px"
|
||||
@close="handleClose"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="90px">
|
||||
<el-form-item label="笔记标题" prop="title">
|
||||
<el-input v-model="form.title" autocomplete="off"></el-input>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
autocomplete="off"
|
||||
placeholder="请输入笔记标题"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择分类" prop="groupingId">
|
||||
<el-cascader
|
||||
v-model="form.groupingId"
|
||||
:options="categoryOptions"
|
||||
:props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'grouping' }"
|
||||
:props="cascaderProps"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择笔记所属分类"
|
||||
style="width: 100%;"
|
||||
></el-cascader>
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="category-option">
|
||||
<el-icon class="category-icon">
|
||||
<Folder v-if="data.children && data.children.length > 0" />
|
||||
<Document v-else />
|
||||
</el-icon>
|
||||
<span class="category-name">{{ data.grouping }}</span>
|
||||
<span v-if="data.children && data.children.length > 0" class="category-count">
|
||||
({{ data.children.length }})
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-cascader>
|
||||
</el-form-item>
|
||||
<el-form-item label="私密笔记">
|
||||
<el-switch
|
||||
@@ -41,6 +61,7 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Folder, Document } from '@element-plus/icons-vue';
|
||||
import { updateMarkdown } from '@/api/CommonApi.js';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -68,6 +89,15 @@ const rules = ref({
|
||||
groupingId: [{ required: true, message: '请选择分类', trigger: 'change' }],
|
||||
});
|
||||
|
||||
// 级联选择器配置
|
||||
const cascaderProps = {
|
||||
checkStrictly: true,
|
||||
emitPath: false,
|
||||
value: 'id',
|
||||
label: 'grouping',
|
||||
children: 'children'
|
||||
};
|
||||
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal && formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
@@ -104,6 +134,26 @@ const handleSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
color: #409eff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-item-help {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
|
||||
@@ -5,25 +5,56 @@
|
||||
width="400px"
|
||||
@close="handleClose"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<el-cascader
|
||||
v-model="moveToGroupId"
|
||||
:options="categoryOptions"
|
||||
:props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'grouping' }"
|
||||
clearable
|
||||
placeholder="请选择目标分类"
|
||||
style="width: 100%;"
|
||||
></el-cascader>
|
||||
<div v-if="noteToMove" class="move-note-info">
|
||||
<el-icon class="note-icon"><Document /></el-icon>
|
||||
<span class="note-title" :title="noteToMove.title">{{ noteToMove.title }}</span>
|
||||
<el-tag v-if="currentGroupName" type="info" size="small" effect="plain">{{ currentGroupName }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="select-wrapper">
|
||||
<el-select-v2
|
||||
v-model="moveToGroupId"
|
||||
:options="flattenOptions"
|
||||
placeholder="请选择目标分类"
|
||||
clearable
|
||||
filterable
|
||||
:height="200"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="select-option" :class="{ 'is-current': item.value === noteToMove?.groupingId }">
|
||||
<span class="indent" :style="{ width: item.indent + 'px' }"></span>
|
||||
<el-icon class="option-icon">
|
||||
<Folder v-if="item.hasChildren" />
|
||||
<Document v-else />
|
||||
</el-icon>
|
||||
<span class="option-label">{{ item.label }}</span>
|
||||
<el-tag v-if="item.value === noteToMove?.groupingId" type="warning" size="small">当前</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-select-v2>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:disabled="!moveToGroupId || moveToGroupId === noteToMove?.groupingId"
|
||||
:loading="isLoading"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Folder, Document } from '@element-plus/icons-vue';
|
||||
import { updateMarkdown } from '@/api/CommonApi.js';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -44,10 +75,53 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:visible', 'move-success']);
|
||||
|
||||
const moveToGroupId = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// 扁平化选项,用于虚拟选择器
|
||||
const flattenOptions = computed(() => {
|
||||
const result = [];
|
||||
const flatten = (options, depth = 0) => {
|
||||
for (const option of options) {
|
||||
result.push({
|
||||
value: option.id,
|
||||
label: option.grouping,
|
||||
indent: depth * 20,
|
||||
hasChildren: option.children && option.children.length > 0
|
||||
});
|
||||
if (option.children && option.children.length > 0) {
|
||||
flatten(option.children, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
flatten(props.categoryOptions);
|
||||
return result;
|
||||
});
|
||||
|
||||
// 使用 Map 缓存分类名称,O(1) 查找
|
||||
const groupNameMap = computed(() => {
|
||||
const map = new Map();
|
||||
const traverse = (options) => {
|
||||
for (const option of options) {
|
||||
map.set(option.id, option.grouping);
|
||||
if (option.children && option.children.length > 0) {
|
||||
traverse(option.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
traverse(props.categoryOptions);
|
||||
return map;
|
||||
});
|
||||
|
||||
// 计算当前分类名称
|
||||
const currentGroupName = computed(() => {
|
||||
if (!props.noteToMove?.groupingId) return '';
|
||||
return groupNameMap.value.get(props.noteToMove.groupingId) || '';
|
||||
});
|
||||
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
moveToGroupId.value = null;
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,7 +138,12 @@ const handleSubmit = async () => {
|
||||
ElMessage.error('没有需要移动的笔记');
|
||||
return;
|
||||
}
|
||||
if (moveToGroupId.value === props.noteToMove.groupingId) {
|
||||
ElMessage.warning('不能移动到当前分类');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
...props.noteToMove,
|
||||
@@ -76,6 +155,69 @@ const handleSubmit = async () => {
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
ElMessage.error('移动失败: ' + error.message);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.move-note-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #409eff;
|
||||
}
|
||||
|
||||
.note-icon {
|
||||
color: #409eff;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.select-option.is-current {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.indent {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
color: #409eff;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user