feat: add memo

This commit is contained in:
2025-09-27 20:39:42 +08:00
parent 24256f588c
commit 700267d7d1
9 changed files with 2286 additions and 2 deletions
+1
View File
@@ -54,6 +54,7 @@ INSTALLED_APPS = [
"email_notice", "email_notice",
"personnel", "personnel",
"scheduler", "scheduler",
"memo",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
+1
View File
@@ -27,6 +27,7 @@ urlpatterns = [
path("", include("finance.urls")), path("", include("finance.urls")),
path("", include("email_notice.urls")), path("", include("email_notice.urls")),
path("", include("personnel.urls")), path("", include("personnel.urls")),
path("", include("memo.urls")),
path("api-auth/", include("rest_framework.urls")), path("api-auth/", include("rest_framework.urls")),
path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
+2
View File
@@ -11,9 +11,11 @@
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.12.2", "axios": "^1.12.2",
"core-js": "^3.8.3", "core-js": "^3.8.3",
"dompurify": "^3.2.7",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"element-plus": "^2.11.2", "element-plus": "^2.11.2",
"eslint-plugin-vue": "^10.4.0", "eslint-plugin-vue": "^10.4.0",
"marked": "^16.3.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
+11 -2
View File
@@ -41,6 +41,10 @@
<el-icon><Tickets /></el-icon> <el-icon><Tickets /></el-icon>
财务记录 财务记录
</el-menu-item> </el-menu-item>
<el-menu-item index="/memo">
<el-icon><EditPen /></el-icon>
备忘录
</el-menu-item>
</el-menu> </el-menu>
<div class="header-actions"> <div class="header-actions">
<el-button type="text" class="settings-btn" @click="goToSettings" aria-label="设置"> <el-button type="text" class="settings-btn" @click="goToSettings" aria-label="设置">
@@ -86,13 +90,17 @@
<el-icon><Tickets /></el-icon> <el-icon><Tickets /></el-icon>
财务记录 财务记录
</el-menu-item> </el-menu-item>
<el-menu-item index="/memo">
<el-icon><EditPen /></el-icon>
备忘录
</el-menu-item>
</el-menu> </el-menu>
</el-drawer> </el-drawer>
</el-header> </el-header>
</template> </template>
<script> <script>
import {Box, Document, House, Money, OfficeBuilding, Setting, Tickets, User, Menu} from '@element-plus/icons-vue' import {Box, Document, House, Money, OfficeBuilding, Setting, Tickets, User, Menu, EditPen} from '@element-plus/icons-vue'
export default { export default {
name: 'AppHeader', name: 'AppHeader',
@@ -105,7 +113,8 @@ export default {
Setting, Setting,
User, User,
OfficeBuilding, OfficeBuilding,
Menu Menu,
EditPen
}, },
data() { data() {
return { return {
File diff suppressed because it is too large Load Diff
+300
View File
@@ -0,0 +1,300 @@
<template>
<div class="memo-list">
<div class="memo-list-header">
<div class="search-bar">
<el-input
v-model="localSearchText"
placeholder="搜索备忘录..."
prefix-icon="Search"
@input="handleSearchInput"
clearable
/>
</div>
<el-button
type="primary"
@click="$emit('create')"
class="create-btn"
:icon="Plus"
>
新建
</el-button>
</div>
<div class="memo-list-content">
<el-loading v-if="loading" class="loading-container" text="加载中..." />
<div v-else-if="memos.length === 0" class="empty-state">
<el-empty description="暂无备忘录" />
</div>
<div v-else class="memo-items">
<div
v-for="memo in memos"
:key="memo.id"
class="memo-item"
:class="{ active: selectedMemoId === memo.id }"
@click="$emit('select', memo)"
>
<div class="memo-item-header">
<h3 class="memo-title">{{ memo.title || '无标题' }}</h3>
<el-dropdown trigger="click" @command="(command) => handleCommand(command, memo)">
<el-button text size="small" class="more-btn">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="delete" class="delete-item">
<el-icon><Delete /></el-icon>
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<p class="memo-preview">{{ memo.content_preview || '无内容' }}</p>
<div class="memo-meta">
<span class="memo-author">{{ memo.created_by_name }}</span>
<span class="memo-date">{{ formatDate(memo.updated_at) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, watch } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { Plus, MoreFilled, Delete } from '@element-plus/icons-vue'
export default {
name: 'MemoList',
props: {
memos: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
searchText: {
type: String,
default: ''
},
selectedMemoId: {
type: [Number, String],
default: null
}
},
emits: ['search', 'select', 'create', 'delete'],
setup(props, { emit }) {
const localSearchText = ref(props.searchText)
let searchTimeout = null
const handleSearchInput = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('search', localSearchText.value)
}, 300)
}
const handleCommand = async (command, memo) => {
if (command === 'delete') {
try {
await ElMessageBox.confirm(
`确定要删除备忘录 "${memo.title}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
emit('delete', memo.id)
} catch {
// 用户取消删除
}
}
}
const formatDate = (dateStr) => {
const date = new Date(dateStr)
const now = new Date()
const diff = now - date
// 如果是今天
if (diff < 24 * 60 * 60 * 1000 && now.getDate() === date.getDate()) {
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 如果是今年
if (now.getFullYear() === date.getFullYear()) {
return date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric'
})
}
// 其他情况显示完整日期
return date.toLocaleDateString('zh-CN')
}
watch(() => props.searchText, (newVal) => {
localSearchText.value = newVal
})
return {
localSearchText,
handleSearchInput,
handleCommand,
formatDate,
Plus,
MoreFilled,
Delete
}
}
}
</script>
<style scoped>
.memo-list {
height: 100%;
display: flex;
flex-direction: column;
}
.memo-list-header {
padding: 16px;
border-bottom: 1px solid #e4e7ed;
background-color: #fafafa;
}
.search-bar {
margin-bottom: 12px;
}
.create-btn {
width: 100%;
}
.memo-list-content {
flex: 1;
overflow-y: auto;
}
.loading-container {
height: 200px;
}
.empty-state {
padding: 40px 20px;
text-align: center;
}
.memo-items {
padding: 0;
}
.memo-item {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.memo-item:hover {
background-color: #f8f9fa;
}
.memo-item.active {
background-color: #e8f4fd;
border-left: 3px solid #409eff;
}
.memo-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.memo-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin: 0;
flex: 1;
padding-right: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.more-btn {
opacity: 0;
transition: opacity 0.2s;
color: #909399;
padding: 4px;
}
.memo-item:hover .more-btn {
opacity: 1;
}
.memo-preview {
font-size: 12px;
color: #606266;
line-height: 1.4;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.memo-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: #909399;
}
.memo-author {
font-weight: 500;
}
.memo-date {
font-variant-numeric: tabular-nums;
}
.delete-item {
color: #f56c6c;
}
/* 滚动条样式 */
.memo-list-content::-webkit-scrollbar {
width: 6px;
}
.memo-list-content::-webkit-scrollbar-track {
background: #f1f1f1;
}
.memo-list-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.memo-list-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
+6
View File
@@ -11,6 +11,7 @@ import FinanceRecordDetail from '../views/FinanceRecordDetail.vue'
import PersonnelList from '../views/PersonnelList.vue' import PersonnelList from '../views/PersonnelList.vue'
import DepartmentProjectGroupManagement from '../views/DepartmentProjectGroupManagement.vue' import DepartmentProjectGroupManagement from '../views/DepartmentProjectGroupManagement.vue'
import Settings from '../views/Settings.vue' import Settings from '../views/Settings.vue'
import Memo from '../views/Memo.vue'
import { authService } from '@/services/api' import { authService } from '@/services/api'
const routes = [ const routes = [
@@ -75,6 +76,11 @@ const routes = [
path: '/settings', path: '/settings',
name: 'Settings', name: 'Settings',
component: Settings component: Settings
},
{
path: '/memo',
name: 'Memo',
component: Memo
} }
] ]
+49
View File
@@ -428,3 +428,52 @@ export const projectGroupService = {
}) })
} }
} }
// 备忘录管理服务
export const memoService = {
// 获取所有备忘录
getMemos(search = '') {
const params = search ? { search } : {}
return apiClient.get('/memos/', { params })
},
// 获取备忘录详情
getMemo(id) {
return apiClient.get(`/memos/${id}/`)
},
// 创建新备忘录
createMemo(memo) {
return apiClient.post('/memos/', memo)
},
// 更新备忘录
updateMemo(id, memo) {
return apiClient.put(`/memos/${id}/`, memo)
},
// 删除备忘录
deleteMemo(id) {
return apiClient.delete(`/memos/${id}/`)
},
// 上传图片到备忘录
uploadImage(memoId, file, altText = '') {
const formData = new FormData()
formData.append('image', file)
formData.append('alt_text', altText)
return apiClient.post(`/memos/${memoId}/upload_image/`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 删除备忘录中的图片
deleteImage(memoId, imageId) {
return apiClient.delete(`/memos/${memoId}/delete_image/`, {
data: { image_id: imageId }
})
}
}
+276
View File
@@ -0,0 +1,276 @@
<template>
<AppHeader />
<div class="memo-container">
<div class="memo-sidebar">
<MemoList
:memos="memos"
:loading="loading"
:search-text="searchText"
@search="handleSearch"
@select="handleSelectMemo"
@create="handleCreateMemo"
@delete="handleDeleteMemo"
:selected-memo-id="selectedMemoId"
/>
</div>
<div class="memo-editor">
<MemoEditor
:memo="selectedMemo"
:loading="editorLoading"
@save="handleSaveMemo"
@upload-image="handleUploadImage"
/>
</div>
</div>
</template>
<script>
import { ref, reactive, onMounted, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import MemoList from '../components/MemoList.vue'
import MemoEditor from '../components/MemoEditor.vue'
import { memoService } from '@/services/api'
import { API_BASE_URL_WITHOUT_API } from '@/services/api'
import AppHeader from "@/components/AppHeader.vue";
export default {
name: 'Memo',
components: {
AppHeader,
MemoList,
MemoEditor
},
setup() {
const memos = ref([])
const selectedMemo = ref(null)
const selectedMemoId = ref(null)
const loading = ref(false)
const editorLoading = ref(false)
const searchText = ref('')
const loadMemos = async (search = '') => {
try {
loading.value = true
const response = await memoService.getMemos(search)
memos.value = response.data.results || response.data
} catch (error) {
console.error('加载备忘录失败:', error)
ElMessage.error('加载备忘录失败')
} finally {
loading.value = false
}
}
const handleSearch = (text) => {
searchText.value = text
loadMemos(text)
}
const handleSelectMemo = async (memo) => {
if (selectedMemoId.value === memo.id) return
try {
editorLoading.value = true
selectedMemoId.value = memo.id
const response = await memoService.getMemo(memo.id)
selectedMemo.value = response.data
} catch (error) {
console.error('加载备忘录详情失败:', error)
ElMessage.error('加载备忘录详情失败')
} finally {
editorLoading.value = false
}
}
const handleCreateMemo = async () => {
try {
// 先清空当前选择
selectedMemo.value = null
selectedMemoId.value = null
// 等待下一个 tick 确保 DOM 更新完成
await nextTick()
const newMemo = {
id: null,
title: '新建备忘录',
content: '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
selectedMemo.value = newMemo
} catch (error) {
console.error('创建新备忘录失败:', error)
ElMessage.error('创建新备忘录失败')
}
}
const handleSaveMemo = async (memo, isAutoSave = false) => {
try {
editorLoading.value = true
let response
const isNewMemo = !memo.id || memo.id === null || memo.id === undefined
if (isNewMemo) {
response = await memoService.createMemo(memo)
selectedMemoId.value = response.data.id
} else {
response = await memoService.updateMemo(memo.id, memo)
}
selectedMemo.value = response.data
// 只在手动保存时显示成功消息
if (!isAutoSave) {
ElMessage.success('保存成功')
}
// 只在创建新备忘录或手动保存时重新加载列表
// 自动保存时避免重新加载以保持用户的滚动位置和焦点
if (isNewMemo || !isAutoSave) {
await loadMemos(searchText.value)
} else {
// 自动保存时只更新当前备忘录在列表中的显示(如果需要的话)
const memoIndex = memos.value.findIndex(m => m.id === memo.id)
if (memoIndex !== -1) {
// 更新列表中的备忘录信息,但不重新加载整个列表
memos.value[memoIndex] = {
...memos.value[memoIndex],
title: response.data.title,
content_preview: response.data.content?.substring(0, 100) || '',
updated_at: response.data.updated_at
}
}
}
} catch (error) {
console.error('保存备忘录失败:', error)
ElMessage.error('保存失败')
} finally {
editorLoading.value = false
}
}
const handleDeleteMemo = async (memoId) => {
try {
await memoService.deleteMemo(memoId)
ElMessage.success('删除成功')
// 如果删除的是当前选中的备忘录,清空编辑器
if (selectedMemoId.value === memoId) {
selectedMemo.value = null
selectedMemoId.value = null
}
// 重新加载列表
await loadMemos(searchText.value)
} catch (error) {
console.error('删除备忘录失败:', error)
ElMessage.error('删除失败')
}
}
const handleUploadImage = async (file, memoId) => {
try {
const response = await memoService.uploadImage(memoId, file)
// 上传成功后,将图片插入到编辑器中
if (response.data && response.data.image) {
// 确保图片路径是完整的URL
const imageUrl = response.data.image.startsWith('http')
? response.data.image
: `${API_BASE_URL_WITHOUT_API}${response.data.image}`
const imageMarkdown = `![${file.name}](${imageUrl})\n`
// 找到文本域并插入图片标记
const textarea = document.querySelector('.content-textarea textarea')
if (textarea && textarea.parentNode) {
const start = textarea.selectionStart || textarea.value.length
const end = textarea.selectionEnd || textarea.value.length
const currentContent = selectedMemo.value.content || ''
selectedMemo.value.content =
currentContent.substring(0, start) +
imageMarkdown +
currentContent.substring(end)
// 更新光标位置
setTimeout(() => {
if (textarea && textarea.parentNode && textarea.focus && textarea.setSelectionRange) {
textarea.focus()
textarea.setSelectionRange(start + imageMarkdown.length, start + imageMarkdown.length)
}
}, 0)
}
ElMessage.success('图片上传成功')
}
return response.data
} catch (error) {
console.error('上传图片失败:', error)
ElMessage.error('上传图片失败')
throw error
}
}
onMounted(() => {
loadMemos()
})
return {
memos,
selectedMemo,
selectedMemoId,
loading,
editorLoading,
searchText,
handleSearch,
handleSelectMemo,
handleCreateMemo,
handleSaveMemo,
handleDeleteMemo,
handleUploadImage
}
}
}
</script>
<style scoped>
.memo-container {
display: flex;
height: calc(100vh - 60px);
background-color: #f5f5f5;
}
.memo-sidebar {
width: 350px;
min-width: 300px;
border-right: 1px solid #e4e7ed;
background-color: #ffffff;
overflow: hidden;
}
.memo-editor {
flex: 1;
background-color: #ffffff;
overflow: hidden;
}
@media (max-width: 768px) {
.memo-container {
flex-direction: column;
}
.memo-sidebar {
width: 100%;
height: 200px;
border-right: none;
border-bottom: 1px solid #e4e7ed;
}
.memo-editor {
height: calc(100vh - 260px);
}
}
</style>