feat: 优化分类选择器与笔记移动功能

This commit is contained in:
ikmkj
2026-03-03 16:57:10 +08:00
parent 392cc52fd2
commit a99696ff7a
10 changed files with 366 additions and 52 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -31,5 +31,13 @@ export const useUserStore = defineStore('user', {
getters: {
isLoggedIn: (state) => !!state.token,
},
persist: true,
persist: {
enabled: true,
strategies: [
{
key: 'user-store',
storage: sessionStorage,
}
],
},
});

View File

@@ -15,9 +15,13 @@ const instance = axios.create({
// 请求拦截器
instance.interceptors.request.use(
config => {
const userStore = useUserStore()
if (userStore.token) {
config.headers['Authorization'] = `Bearer ${userStore.token}`
try {
const userStore = useUserStore()
if (userStore.token) {
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
} catch (error) {
console.warn('Failed to get user store:', error)
}
return config
},
@@ -43,10 +47,14 @@ instance.interceptors.response.use(
},
error => {
if (error.response && error.response.status === 401) {
const userStore = useUserStore();
userStore.logout();
ElMessage.error('登录已过期,请重新登录');
router.push('/login');
try {
const userStore = useUserStore()
userStore.logout();
ElMessage.error('登录已过期,请重新登录');
router.push('/login');
} catch (error) {
console.warn('Failed to get user store:', error)
}
} else {
ElMessage({
message: error.message,