321
This commit is contained in:
parent
22647099c2
commit
e8816c1ffb
|
|
@ -641,15 +641,38 @@ function renderSearchResult(bookmark) {
|
|||
- [x] 更新控制器
|
||||
- [x] 构建验证通过
|
||||
|
||||
### 8.2 前端重构(待开始)
|
||||
### 8.2 前端重构(已完成 ✅)
|
||||
|
||||
- [ ] 修改类型定义 (types/index.ts)
|
||||
- [ ] 修改 API 层 (api/bookmark.ts, api/tag.ts)
|
||||
- [ ] 修改 Store 层 (stores/bookmark.ts, stores/tag.ts)
|
||||
- [ ] 修改 BookmarkList 组件
|
||||
- [ ] 修改 BookmarkEditor 组件
|
||||
- [ ] 新增文件夹相关组件
|
||||
- [ ] 新增标签管理组件
|
||||
- [x] 修改类型定义 (types/index.ts)
|
||||
- Tag 接口新增 id、color、icon、order 字段
|
||||
- Bookmark.tags 从 string[] 改为 Tag[]
|
||||
- Bookmark.lastVisitTime 改名为 lastVisitAt
|
||||
- 新增 folderId 字段
|
||||
- 新增 Folder、Collection 接口
|
||||
- [x] 修改 API 层 (api/bookmark.ts, api/tag.ts)
|
||||
- getBookmarks 新增 folderId 参数
|
||||
- 新增文件夹相关 API
|
||||
- 标签 API 改用 ID
|
||||
- [x] 修改 Store 层 (stores/bookmark.ts, stores/tag.ts)
|
||||
- 新增 folders、currentFolderId 状态
|
||||
- 新增文件夹操作方法
|
||||
- 标签操作改用 ID
|
||||
- [x] 修改 BookmarkList 组件
|
||||
- 标签显示改为 tag.name
|
||||
- 支持显示标签颜色
|
||||
- [x] 修改 BookmarkEditor 组件
|
||||
- 适配新的标签对象结构
|
||||
- [x] 新增文件夹相关组件
|
||||
- FolderTree.vue - 文件夹树形组件
|
||||
- [x] 新增标签管理组件
|
||||
- TagBadge.vue - 带颜色的标签徽章组件
|
||||
- TagManager.vue - 标签管理弹窗
|
||||
- [x] 修改 HomeView.vue
|
||||
- 添加文件夹导航
|
||||
- 标签显示颜色
|
||||
- 面包屑导航
|
||||
- 标签管理入口
|
||||
- [x] 构建验证通过
|
||||
|
||||
### 8.3 浏览器插件重构(待开始)
|
||||
|
||||
|
|
@ -672,7 +695,8 @@ function renderSearchResult(bookmark) {
|
|||
|
||||
## 十、版本信息
|
||||
|
||||
- 文档版本:v2.0
|
||||
- 文档版本:v2.1
|
||||
- 创建日期:2024-12-25
|
||||
- 最后更新:2024-12-25
|
||||
- 作者:Claude Code
|
||||
- 更新说明:完成前端重构
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { http } from './client'
|
||||
import type { Bookmark, CreateBookmarkRequest, UpdateBookmarkRequest, VisibilityType } from '@/types'
|
||||
import type { Bookmark, CreateBookmarkRequest, UpdateBookmarkRequest, VisibilityType, Folder } from '@/types'
|
||||
|
||||
export const bookmarkApi = {
|
||||
/**
|
||||
* 获取书签列表
|
||||
*/
|
||||
async getBookmarks(tag?: string) {
|
||||
const params = tag ? { tag } : undefined
|
||||
return http.get<Bookmark[]>('/bookmarks', { params })
|
||||
async getBookmarks(tag?: string, folderId?: string) {
|
||||
const params: Record<string, string> = {}
|
||||
if (tag) params.tag = tag
|
||||
if (folderId) params.folderId = folderId
|
||||
return http.get<Bookmark[]>('/bookmarks', { params: Object.keys(params).length ? params : undefined })
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -92,6 +94,41 @@ export const bookmarkApi = {
|
|||
*/
|
||||
async exportBookmarks() {
|
||||
return http.get<Bookmark[]>('/bookmarks/export')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取文件夹列表
|
||||
*/
|
||||
async getFolders() {
|
||||
return http.get<Folder[]>('/folders')
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建文件夹
|
||||
*/
|
||||
async createFolder(data: { name: string; parentId?: string; icon?: string }) {
|
||||
return http.post<Folder>('/folders', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新文件夹
|
||||
*/
|
||||
async updateFolder(id: string, data: { name?: string; icon?: string; order?: number }) {
|
||||
return http.put<Folder>(`/folders/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除文件夹
|
||||
*/
|
||||
async deleteFolder(id: string) {
|
||||
return http.delete(`/folders/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 移动书签到文件夹
|
||||
*/
|
||||
async moveBookmarkToFolder(bookmarkId: string, folderId: string | null) {
|
||||
return http.put(`/bookmarks/${bookmarkId}/folder`, { folderId })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import { http } from './client'
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
export interface CreateTagRequest {
|
||||
name: string
|
||||
color?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
name?: string
|
||||
color?: string
|
||||
icon?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
export const tagApi = {
|
||||
/**
|
||||
* 获取标签列表
|
||||
|
|
@ -10,24 +23,37 @@ export const tagApi = {
|
|||
},
|
||||
|
||||
/**
|
||||
* 重命名标签
|
||||
* 获取标签详情
|
||||
*/
|
||||
async renameTag(oldName: string, newName: string) {
|
||||
return http.put(`/tags/${encodeURIComponent(oldName)}`, { newName })
|
||||
async getTag(id: string) {
|
||||
return http.get<Tag>(`/tags/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除标签
|
||||
* 创建标签
|
||||
*/
|
||||
async deleteTag(name: string) {
|
||||
return http.delete<number>(`/tags/${encodeURIComponent(name)}`)
|
||||
async createTag(data: CreateTagRequest) {
|
||||
return http.post<Tag>('/tags', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 合并标签
|
||||
* 更新标签
|
||||
*/
|
||||
async mergeTags(sourceNames: string[], targetName: string) {
|
||||
return http.post<number>('/tags/merge', { sourceNames, targetName })
|
||||
async updateTag(id: string, data: UpdateTagRequest) {
|
||||
return http.put<Tag>(`/tags/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除标签(通过 ID)
|
||||
*/
|
||||
async deleteTag(id: string) {
|
||||
return http.delete<number>(`/tags/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 合并标签(使用标签 ID)
|
||||
*/
|
||||
async mergeTags(sourceTagIds: string[], targetTagId: string) {
|
||||
return http.post<number>('/tags/merge', { sourceTagIds, targetTagId })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ watch(() => props.visible, async (val) => {
|
|||
form.title = props.bookmark.title
|
||||
form.url = props.bookmark.url
|
||||
form.description = props.bookmark.description || ''
|
||||
form.tags = [...props.bookmark.tags]
|
||||
form.tags = props.bookmark.tags.map(t => t.name)
|
||||
form.visibility = props.bookmark.visibility
|
||||
form.allowedDevices = props.bookmark.allowedDevices || []
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,19 @@ function getVisibilityType(visibility: VisibilityType) {
|
|||
return map[visibility] || 'success'
|
||||
}
|
||||
|
||||
// 根据背景色计算对比色(黑或白)
|
||||
function getContrastColor(hexColor: string): string {
|
||||
// 移除 # 号
|
||||
const hex = hexColor.replace('#', '')
|
||||
// 解析 RGB
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
// 计算亮度
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
async function handleVisit(bookmark: Bookmark) {
|
||||
await bookmarkStore.recordVisit(bookmark.id)
|
||||
window.open(bookmark.url, '_blank')
|
||||
|
|
@ -148,11 +161,12 @@ function handleSelectionChange(selection: Bookmark[]) {
|
|||
<div class="tags">
|
||||
<el-tag
|
||||
v-for="tag in row.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
:key="tag.id"
|
||||
size="small"
|
||||
type="info"
|
||||
:color="tag.color"
|
||||
:style="tag.color ? { color: getContrastColor(tag.color), borderColor: tag.color } : {}"
|
||||
>
|
||||
{{ tag }}
|
||||
{{ tag.name }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.tags.length > 3" size="small" type="info">
|
||||
+{{ row.tags.length - 3 }}
|
||||
|
|
|
|||
262
src/frontend/src/components/folder/FolderTree.vue
Normal file
262
src/frontend/src/components/folder/FolderTree.vue
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Folder, FolderOpened } from '@element-plus/icons-vue'
|
||||
import { useBookmarkStore } from '@/stores/bookmark'
|
||||
import type { Folder as FolderType } from '@/types'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
folders: FolderType[]
|
||||
currentFolderId?: string | null
|
||||
editable?: boolean
|
||||
}>(), {
|
||||
currentFolderId: null,
|
||||
editable: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [folderId: string | null]
|
||||
create: [parentId: string | null]
|
||||
}>()
|
||||
|
||||
const bookmarkStore = useBookmarkStore()
|
||||
const expandedKeys = ref<string[]>([])
|
||||
const showCreateDialog = ref(false)
|
||||
const createParentId = ref<string | null>(null)
|
||||
const newFolderName = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
// 构建树形数据
|
||||
const treeData = computed(() => {
|
||||
const buildTree = (folders: FolderType[], parentId?: string): any[] => {
|
||||
return folders
|
||||
.filter(f => f.parentId === parentId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(folder => ({
|
||||
id: folder.id,
|
||||
label: folder.name,
|
||||
icon: folder.icon,
|
||||
children: buildTree(folders, folder.id)
|
||||
}))
|
||||
}
|
||||
return buildTree(props.folders, undefined)
|
||||
})
|
||||
|
||||
function handleNodeClick(data: { id: string }) {
|
||||
emit('select', data.id)
|
||||
}
|
||||
|
||||
function handleSelectAll() {
|
||||
emit('select', null)
|
||||
}
|
||||
|
||||
function handleCreateFolder(parentId: string | null = null) {
|
||||
createParentId.value = parentId
|
||||
newFolderName.value = ''
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
async function handleConfirmCreate() {
|
||||
if (!newFolderName.value.trim()) {
|
||||
ElMessage.warning('请输入文件夹名称')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const response = await bookmarkStore.createFolder(
|
||||
newFolderName.value.trim(),
|
||||
createParentId.value ?? undefined
|
||||
)
|
||||
if (response.success) {
|
||||
ElMessage.success('创建成功')
|
||||
showCreateDialog.value = false
|
||||
} else {
|
||||
ElMessage.error(response.message || '创建失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFolder(folderId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除此文件夹吗?文件夹内的书签将移至根目录。',
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const response = await bookmarkStore.deleteFolder(folderId)
|
||||
if (response.success) {
|
||||
ElMessage.success('删除成功')
|
||||
if (props.currentFolderId === folderId) {
|
||||
emit('select', null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="folder-tree">
|
||||
<!-- 全部书签 -->
|
||||
<div
|
||||
class="folder-item root-item"
|
||||
:class="{ active: !currentFolderId }"
|
||||
@click="handleSelectAll"
|
||||
>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span>全部书签</span>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹树 -->
|
||||
<el-tree
|
||||
v-if="treeData.length > 0"
|
||||
:data="treeData"
|
||||
:expand-on-click-node="false"
|
||||
:default-expanded-keys="expandedKeys"
|
||||
node-key="id"
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<div
|
||||
class="folder-item"
|
||||
:class="{ active: currentFolderId === data.id }"
|
||||
>
|
||||
<el-icon><Folder /></el-icon>
|
||||
<span class="folder-name">{{ node.label }}</span>
|
||||
<div v-if="editable" class="folder-actions" @click.stop>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="Plus"
|
||||
circle
|
||||
@click="handleCreateFolder(data.id)"
|
||||
/>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="Delete"
|
||||
circle
|
||||
type="danger"
|
||||
@click="handleDeleteFolder(data.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-tip">
|
||||
暂无文件夹
|
||||
</div>
|
||||
|
||||
<!-- 创建文件夹按钮 -->
|
||||
<div v-if="editable" class="create-folder-btn" @click="handleCreateFolder(null)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新建文件夹</span>
|
||||
</div>
|
||||
|
||||
<!-- 创建文件夹弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showCreateDialog"
|
||||
title="新建文件夹"
|
||||
width="400px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-input
|
||||
v-model="newFolderName"
|
||||
placeholder="请输入文件夹名称"
|
||||
@keyup.enter="handleConfirmCreate"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="handleConfirmCreate">
|
||||
创建
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder-tree {
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.root-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-actions {
|
||||
display: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&:hover .folder-actions {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.create-folder-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #667eea;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f4ff;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__expand-icon) {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
123
src/frontend/src/components/tag/TagBadge.vue
Normal file
123
src/frontend/src/components/tag/TagBadge.vue
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script setup lang="ts">
|
||||
import type { Tag } from '@/types'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tag: Tag
|
||||
size?: 'small' | 'default' | 'large'
|
||||
closable?: boolean
|
||||
clickable?: boolean
|
||||
}>(), {
|
||||
size: 'default',
|
||||
closable: false,
|
||||
clickable: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [tag: Tag]
|
||||
close: [tag: Tag]
|
||||
}>()
|
||||
|
||||
// 根据背景色计算对比色(黑或白)
|
||||
function getContrastColor(hexColor: string): string {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (props.clickable) {
|
||||
emit('click', props.tag)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose(e: Event) {
|
||||
e.stopPropagation()
|
||||
emit('close', props.tag)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="tag-badge"
|
||||
:class="[
|
||||
`tag-badge--${size}`,
|
||||
{ 'tag-badge--clickable': clickable }
|
||||
]"
|
||||
:style="tag.color ? {
|
||||
backgroundColor: tag.color,
|
||||
color: getContrastColor(tag.color),
|
||||
borderColor: tag.color
|
||||
} : {}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span v-if="tag.icon" class="tag-icon">{{ tag.icon }}</span>
|
||||
<span class="tag-name">{{ tag.name }}</span>
|
||||
<span v-if="closable" class="tag-close" @click="handleClose">
|
||||
<el-icon><Close /></el-icon>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: #f0f0f0;
|
||||
color: #666;
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--small {
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&--large {
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
333
src/frontend/src/components/tag/TagManager.vue
Normal file
333
src/frontend/src/components/tag/TagManager.vue
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import type { Tag } from '@/types'
|
||||
import TagBadge from './TagBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
}>()
|
||||
|
||||
const tagStore = useTagStore()
|
||||
const tags = computed(() => tagStore.sortedTags)
|
||||
|
||||
const editingTag = ref<Tag | null>(null)
|
||||
const showEditDialog = ref(false)
|
||||
const showCreateDialog = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
color: '',
|
||||
icon: ''
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
color: '#667eea',
|
||||
icon: ''
|
||||
})
|
||||
|
||||
// 预设颜色
|
||||
const presetColors = [
|
||||
'#667eea', '#764ba2', '#f56c6c', '#e6a23c', '#67c23a',
|
||||
'#409eff', '#909399', '#ff6b6b', '#4ecdc4', '#45b7d1',
|
||||
'#96c93d', '#f7dc6f', '#bb8fce', '#85c1e9', '#f8b500'
|
||||
]
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
tagStore.fetchTags()
|
||||
}
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function handleEdit(tag: Tag) {
|
||||
editingTag.value = tag
|
||||
editForm.name = tag.name
|
||||
editForm.color = tag.color || ''
|
||||
editForm.icon = tag.icon || ''
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!editingTag.value) return
|
||||
if (!editForm.name.trim()) {
|
||||
ElMessage.warning('请输入标签名称')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await tagStore.updateTag(editingTag.value.id, {
|
||||
name: editForm.name.trim(),
|
||||
color: editForm.color || undefined,
|
||||
icon: editForm.icon || undefined
|
||||
})
|
||||
if (response.success) {
|
||||
ElMessage.success('更新成功')
|
||||
showEditDialog.value = false
|
||||
} else {
|
||||
ElMessage.error(response.message || '更新失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('更新失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
createForm.name = ''
|
||||
createForm.color = '#667eea'
|
||||
createForm.icon = ''
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSaveCreate() {
|
||||
if (!createForm.name.trim()) {
|
||||
ElMessage.warning('请输入标签名称')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await tagStore.createTag(
|
||||
createForm.name.trim(),
|
||||
createForm.color || undefined,
|
||||
createForm.icon || undefined
|
||||
)
|
||||
if (response.success) {
|
||||
ElMessage.success('创建成功')
|
||||
showCreateDialog.value = false
|
||||
} else {
|
||||
ElMessage.error(response.message || '创建失败')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('创建失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(tag: Tag) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除标签 "${tag.name}" 吗?该标签将从所有书签中移除。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const response = await tagStore.deleteTag(tag.id)
|
||||
if (response.success) {
|
||||
ElMessage.success('删除成功')
|
||||
}
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="visible"
|
||||
title="标签管理"
|
||||
direction="rtl"
|
||||
size="400px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="tag-manager">
|
||||
<!-- 创建按钮 -->
|
||||
<div class="manager-header">
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建标签
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 标签列表 -->
|
||||
<div class="tag-list">
|
||||
<div
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="tag-list-item"
|
||||
>
|
||||
<TagBadge :tag="tag" size="default" />
|
||||
<span class="tag-count">{{ tag.count }} 个书签</span>
|
||||
<div class="tag-actions">
|
||||
<el-button size="small" @click="handleEdit(tag)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(tag)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="tags.length === 0" description="暂无标签" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑标签弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showEditDialog"
|
||||
title="编辑标签"
|
||||
width="400px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="editForm.name" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<div class="color-picker">
|
||||
<div class="preset-colors">
|
||||
<span
|
||||
v-for="color in presetColors"
|
||||
:key="color"
|
||||
class="color-item"
|
||||
:class="{ active: editForm.color === color }"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="editForm.color = color"
|
||||
/>
|
||||
</div>
|
||||
<el-color-picker v-model="editForm.color" show-alpha />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="预览">
|
||||
<TagBadge
|
||||
v-if="editForm.name"
|
||||
:tag="{ id: '', name: editForm.name, color: editForm.color, icon: editForm.icon, order: 0, count: 0 }"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSaveEdit">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建标签弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showCreateDialog"
|
||||
title="新建标签"
|
||||
width="400px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="createForm.name" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色">
|
||||
<div class="color-picker">
|
||||
<div class="preset-colors">
|
||||
<span
|
||||
v-for="color in presetColors"
|
||||
:key="color"
|
||||
class="color-item"
|
||||
:class="{ active: createForm.color === color }"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="createForm.color = color"
|
||||
/>
|
||||
</div>
|
||||
<el-color-picker v-model="createForm.color" show-alpha />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="预览">
|
||||
<TagBadge
|
||||
v-if="createForm.name"
|
||||
:tag="{ id: '', name: createForm.name, color: createForm.color, icon: createForm.icon, order: 0, count: 0 }"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSaveCreate">
|
||||
创建
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag-manager {
|
||||
.manager-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
.tag-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
|
||||
.tag-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
flex: 1;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tag-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.preset-colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.color-item {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { bookmarkApi } from '@/api/bookmark'
|
||||
import type { Bookmark, CreateBookmarkRequest, UpdateBookmarkRequest, VisibilityType } from '@/types'
|
||||
import type { Bookmark, CreateBookmarkRequest, UpdateBookmarkRequest, VisibilityType, Folder } from '@/types'
|
||||
|
||||
export const useBookmarkStore = defineStore('bookmark', () => {
|
||||
// 状态
|
||||
const bookmarks = ref<Bookmark[]>([])
|
||||
const folders = ref<Folder[]>([])
|
||||
const loading = ref(false)
|
||||
const currentTag = ref<string | null>(null)
|
||||
const currentFolderId = ref<string | null>(null)
|
||||
const searchKeyword = ref('')
|
||||
const searchResults = ref<Bookmark[]>([])
|
||||
|
||||
|
|
@ -18,8 +20,8 @@ export const useBookmarkStore = defineStore('bookmark', () => {
|
|||
|
||||
const recentBookmarks = computed(() => {
|
||||
return [...bookmarks.value]
|
||||
.filter(b => b.lastVisitTime)
|
||||
.sort((a, b) => new Date(b.lastVisitTime!).getTime() - new Date(a.lastVisitTime!).getTime())
|
||||
.filter(b => b.lastVisitAt)
|
||||
.sort((a, b) => new Date(b.lastVisitAt!).getTime() - new Date(a.lastVisitAt!).getTime())
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
|
|
@ -30,17 +32,26 @@ export const useBookmarkStore = defineStore('bookmark', () => {
|
|||
})
|
||||
|
||||
const filteredBookmarks = computed(() => {
|
||||
if (!currentTag.value) {
|
||||
return sortedBookmarks.value
|
||||
let result = sortedBookmarks.value
|
||||
|
||||
// 按文件夹过滤
|
||||
if (currentFolderId.value) {
|
||||
result = result.filter(b => b.folderId === currentFolderId.value)
|
||||
}
|
||||
return sortedBookmarks.value.filter(b => b.tags.includes(currentTag.value!))
|
||||
|
||||
// 按标签过滤
|
||||
if (currentTag.value) {
|
||||
result = result.filter(b => b.tags.some(t => t.name === currentTag.value))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 获取书签列表
|
||||
async function fetchBookmarks(tag?: string) {
|
||||
async function fetchBookmarks(tag?: string, folderId?: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await bookmarkApi.getBookmarks(tag)
|
||||
const response = await bookmarkApi.getBookmarks(tag, folderId)
|
||||
if (response.success && response.data) {
|
||||
bookmarks.value = response.data
|
||||
}
|
||||
|
|
@ -49,6 +60,53 @@ export const useBookmarkStore = defineStore('bookmark', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 获取文件夹列表
|
||||
async function fetchFolders() {
|
||||
try {
|
||||
const response = await bookmarkApi.getFolders()
|
||||
if (response.success && response.data) {
|
||||
folders.value = response.data
|
||||
}
|
||||
} catch {
|
||||
// 文件夹功能可能尚未实现,静默处理
|
||||
}
|
||||
}
|
||||
|
||||
// 创建文件夹
|
||||
async function createFolder(name: string, parentId?: string, icon?: string) {
|
||||
const response = await bookmarkApi.createFolder({ name, parentId, icon })
|
||||
if (response.success && response.data) {
|
||||
folders.value.push(response.data)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// 删除文件夹
|
||||
async function deleteFolder(id: string) {
|
||||
const response = await bookmarkApi.deleteFolder(id)
|
||||
if (response.success) {
|
||||
folders.value = folders.value.filter(f => f.id !== id)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// 设置当前文件夹
|
||||
function setCurrentFolder(folderId: string | null) {
|
||||
currentFolderId.value = folderId
|
||||
}
|
||||
|
||||
// 移动书签到文件夹
|
||||
async function moveBookmarkToFolder(bookmarkId: string, folderId: string | null) {
|
||||
const response = await bookmarkApi.moveBookmarkToFolder(bookmarkId, folderId)
|
||||
if (response.success) {
|
||||
const bookmark = bookmarks.value.find(b => b.id === bookmarkId)
|
||||
if (bookmark) {
|
||||
bookmark.folderId = folderId ?? undefined
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// 搜索书签
|
||||
async function searchBookmarks(keyword: string) {
|
||||
if (!keyword.trim()) {
|
||||
|
|
@ -126,7 +184,7 @@ export const useBookmarkStore = defineStore('bookmark', () => {
|
|||
const bookmark = bookmarks.value.find(b => b.id === id)
|
||||
if (bookmark) {
|
||||
bookmark.visitCount++
|
||||
bookmark.lastVisitTime = new Date().toISOString()
|
||||
bookmark.lastVisitAt = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,8 +210,10 @@ export const useBookmarkStore = defineStore('bookmark', () => {
|
|||
return {
|
||||
// 状态
|
||||
bookmarks,
|
||||
folders,
|
||||
loading,
|
||||
currentTag,
|
||||
currentFolderId,
|
||||
searchKeyword,
|
||||
searchResults,
|
||||
// 计算属性
|
||||
|
|
@ -163,6 +223,11 @@ export const useBookmarkStore = defineStore('bookmark', () => {
|
|||
filteredBookmarks,
|
||||
// 方法
|
||||
fetchBookmarks,
|
||||
fetchFolders,
|
||||
createFolder,
|
||||
deleteFolder,
|
||||
setCurrentFolder,
|
||||
moveBookmarkToFolder,
|
||||
searchBookmarks,
|
||||
clearSearch,
|
||||
createBookmark,
|
||||
|
|
@ -176,4 +241,3 @@ export const useBookmarkStore = defineStore('bookmark', () => {
|
|||
exportBookmarks
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,18 @@ export const useTagStore = defineStore('tag', () => {
|
|||
const tags = ref<Tag[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 计算属性
|
||||
// 计算属性 - 按 order 排序,如果 order 相同则按使用次数排序
|
||||
const sortedTags = computed(() => {
|
||||
return [...tags.value].sort((a, b) => {
|
||||
if (a.order !== b.order) {
|
||||
return a.order - b.order
|
||||
}
|
||||
return b.count - a.count
|
||||
})
|
||||
})
|
||||
|
||||
// 按使用次数排序
|
||||
const tagsByCount = computed(() => {
|
||||
return [...tags.value].sort((a, b) => b.count - a.count)
|
||||
})
|
||||
|
||||
|
|
@ -17,6 +27,16 @@ export const useTagStore = defineStore('tag', () => {
|
|||
return tags.value.map(t => t.name)
|
||||
})
|
||||
|
||||
// 根据名称查找标签
|
||||
function getTagByName(name: string): Tag | undefined {
|
||||
return tags.value.find(t => t.name === name)
|
||||
}
|
||||
|
||||
// 根据 ID 查找标签
|
||||
function getTagById(id: string): Tag | undefined {
|
||||
return tags.value.find(t => t.id === id)
|
||||
}
|
||||
|
||||
// 获取标签列表
|
||||
async function fetchTags() {
|
||||
loading.value = true
|
||||
|
|
@ -30,30 +50,39 @@ export const useTagStore = defineStore('tag', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// 重命名标签
|
||||
async function renameTag(oldName: string, newName: string) {
|
||||
const response = await tagApi.renameTag(oldName, newName)
|
||||
if (response.success) {
|
||||
const tag = tags.value.find(t => t.name === oldName)
|
||||
if (tag) {
|
||||
tag.name = newName
|
||||
// 创建标签
|
||||
async function createTag(name: string, color?: string, icon?: string) {
|
||||
const response = await tagApi.createTag({ name, color, icon })
|
||||
if (response.success && response.data) {
|
||||
tags.value.push(response.data)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// 更新标签
|
||||
async function updateTag(id: string, data: { name?: string; color?: string; icon?: string; order?: number }) {
|
||||
const response = await tagApi.updateTag(id, data)
|
||||
if (response.success && response.data) {
|
||||
const index = tags.value.findIndex(t => t.id === id)
|
||||
if (index !== -1) {
|
||||
tags.value[index] = response.data
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// 删除标签
|
||||
async function deleteTag(name: string) {
|
||||
const response = await tagApi.deleteTag(name)
|
||||
// 删除标签(通过 ID)
|
||||
async function deleteTag(id: string) {
|
||||
const response = await tagApi.deleteTag(id)
|
||||
if (response.success) {
|
||||
tags.value = tags.value.filter(t => t.name !== name)
|
||||
tags.value = tags.value.filter(t => t.id !== id)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// 合并标签
|
||||
async function mergeTags(sourceNames: string[], targetName: string) {
|
||||
const response = await tagApi.mergeTags(sourceNames, targetName)
|
||||
// 合并标签(使用标签 ID)
|
||||
async function mergeTags(sourceTagIds: string[], targetTagId: string) {
|
||||
const response = await tagApi.mergeTags(sourceTagIds, targetTagId)
|
||||
if (response.success) {
|
||||
await fetchTags()
|
||||
}
|
||||
|
|
@ -66,12 +95,15 @@ export const useTagStore = defineStore('tag', () => {
|
|||
loading,
|
||||
// 计算属性
|
||||
sortedTags,
|
||||
tagsByCount,
|
||||
tagNames,
|
||||
// 方法
|
||||
getTagByName,
|
||||
getTagById,
|
||||
fetchTags,
|
||||
renameTag,
|
||||
createTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
mergeTags
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -49,13 +49,14 @@ export interface Device {
|
|||
// 书签信息
|
||||
export interface Bookmark {
|
||||
id: string
|
||||
folderId?: string
|
||||
title: string
|
||||
url: string
|
||||
description?: string
|
||||
icon?: string
|
||||
tags: string[]
|
||||
tags: Tag[]
|
||||
visitCount: number
|
||||
lastVisitTime?: string
|
||||
lastVisitAt?: string
|
||||
order: number
|
||||
visibility: VisibilityType
|
||||
allowedDevices?: string[]
|
||||
|
|
@ -65,10 +66,40 @@ export interface Bookmark {
|
|||
|
||||
// 标签信息
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
icon?: string
|
||||
order: number
|
||||
count: number
|
||||
}
|
||||
|
||||
// 文件夹信息
|
||||
export interface Folder {
|
||||
id: string
|
||||
parentId?: string
|
||||
name: string
|
||||
icon?: string
|
||||
order: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
children?: Folder[]
|
||||
bookmarks?: Bookmark[]
|
||||
}
|
||||
|
||||
// 收藏集合信息
|
||||
export interface Collection {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
isPublic: boolean
|
||||
order: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// API 响应包装
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
|
|
@ -123,6 +154,7 @@ export interface CreateBookmarkRequest {
|
|||
description?: string
|
||||
icon?: string
|
||||
tags?: string[]
|
||||
folderId?: string
|
||||
visibility?: VisibilityType
|
||||
allowedDevices?: string[]
|
||||
}
|
||||
|
|
@ -134,6 +166,8 @@ export interface UpdateBookmarkRequest {
|
|||
description?: string
|
||||
icon?: string
|
||||
tags?: string[]
|
||||
folderId?: string
|
||||
updateFolder?: boolean
|
||||
visibility?: VisibilityType
|
||||
allowedDevices?: string[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import AppLayout from '@/components/layout/AppLayout.vue'
|
|||
import BookmarkList from '@/components/bookmark/BookmarkList.vue'
|
||||
import BookmarkEditor from '@/components/bookmark/BookmarkEditor.vue'
|
||||
import GlobalSearch from '@/components/search/GlobalSearch.vue'
|
||||
import FolderTree from '@/components/folder/FolderTree.vue'
|
||||
import TagManager from '@/components/tag/TagManager.vue'
|
||||
import type { Bookmark } from '@/types'
|
||||
|
||||
const bookmarkStore = useBookmarkStore()
|
||||
|
|
@ -16,15 +18,35 @@ const authStore = useAuthStore()
|
|||
const showEditor = ref(false)
|
||||
const editingBookmark = ref<Bookmark | null>(null)
|
||||
const showSearch = ref(false)
|
||||
const showTagManager = ref(false)
|
||||
|
||||
const tags = computed(() => tagStore.sortedTags)
|
||||
const folders = computed(() => bookmarkStore.folders)
|
||||
const bookmarks = computed(() => bookmarkStore.filteredBookmarks)
|
||||
const currentTag = computed(() => bookmarkStore.currentTag)
|
||||
const currentFolderId = computed(() => bookmarkStore.currentFolderId)
|
||||
const recentBookmarks = computed(() => bookmarkStore.recentBookmarks)
|
||||
|
||||
// 面包屑导航
|
||||
const breadcrumb = computed(() => {
|
||||
const items: { id: string | null; name: string }[] = [
|
||||
{ id: null, name: '全部书签' }
|
||||
]
|
||||
|
||||
if (currentFolderId.value) {
|
||||
const folder = folders.value.find(f => f.id === currentFolderId.value)
|
||||
if (folder) {
|
||||
items.push({ id: folder.id, name: folder.name })
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
bookmarkStore.fetchBookmarks(),
|
||||
bookmarkStore.fetchFolders(),
|
||||
tagStore.fetchTags()
|
||||
])
|
||||
})
|
||||
|
|
@ -48,6 +70,10 @@ function handleTagClick(tagName: string | null) {
|
|||
bookmarkStore.setCurrentTag(tagName)
|
||||
}
|
||||
|
||||
function handleFolderSelect(folderId: string | null) {
|
||||
bookmarkStore.setCurrentFolder(folderId)
|
||||
}
|
||||
|
||||
function handleSearchOpen() {
|
||||
showSearch.value = true
|
||||
}
|
||||
|
|
@ -56,6 +82,20 @@ function handleSearchClose() {
|
|||
showSearch.value = false
|
||||
}
|
||||
|
||||
function handleOpenTagManager() {
|
||||
showTagManager.value = true
|
||||
}
|
||||
|
||||
// 根据背景色计算对比色
|
||||
function getContrastColor(hexColor: string): string {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
// 快捷键
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
|
|
@ -74,37 +114,71 @@ onMounted(() => {
|
|||
<div class="home-view">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>标签</h3>
|
||||
</div>
|
||||
<div class="tag-list">
|
||||
<div
|
||||
class="tag-item"
|
||||
:class="{ active: currentTag === null }"
|
||||
@click="handleTagClick(null)"
|
||||
>
|
||||
<el-icon><Folder /></el-icon>
|
||||
<span>全部书签</span>
|
||||
<span class="count">{{ bookmarkStore.bookmarks.length }}</span>
|
||||
<!-- 文件夹区域 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-header">
|
||||
<h3>文件夹</h3>
|
||||
</div>
|
||||
<div
|
||||
v-for="tag in tags"
|
||||
:key="tag.name"
|
||||
class="tag-item"
|
||||
:class="{ active: currentTag === tag.name }"
|
||||
@click="handleTagClick(tag.name)"
|
||||
>
|
||||
<el-icon><PriceTag /></el-icon>
|
||||
<span class="tag-name">{{ tag.name }}</span>
|
||||
<span class="count">{{ tag.count }}</span>
|
||||
<FolderTree
|
||||
:folders="folders"
|
||||
:current-folder-id="currentFolderId"
|
||||
:editable="true"
|
||||
@select="handleFolderSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 标签区域 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-header">
|
||||
<h3>标签</h3>
|
||||
<el-button size="small" text @click="handleOpenTagManager">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="tag-list">
|
||||
<div
|
||||
class="tag-item"
|
||||
:class="{ active: currentTag === null && !currentFolderId }"
|
||||
@click="handleTagClick(null)"
|
||||
>
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>全部标签</span>
|
||||
<span class="count">{{ bookmarkStore.bookmarks.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="tag-item"
|
||||
:class="{ active: currentTag === tag.name }"
|
||||
@click="handleTagClick(tag.name)"
|
||||
>
|
||||
<span
|
||||
v-if="tag.color"
|
||||
class="tag-color"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
/>
|
||||
<el-icon v-else><PriceTag /></el-icon>
|
||||
<span class="tag-name">{{ tag.name }}</span>
|
||||
<span class="count">{{ tag.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content">
|
||||
<!-- 面包屑导航 -->
|
||||
<el-breadcrumb v-if="currentFolderId" class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item
|
||||
v-for="item in breadcrumb"
|
||||
:key="item.id ?? 'root'"
|
||||
>
|
||||
<a @click="handleFolderSelect(item.id)">{{ item.name }}</a>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
<!-- 最近访问 -->
|
||||
<section v-if="recentBookmarks.length > 0 && !currentTag" class="recent-section">
|
||||
<section v-if="recentBookmarks.length > 0 && !currentTag && !currentFolderId" class="recent-section">
|
||||
<h3 class="section-title">最近访问</h3>
|
||||
<div class="recent-grid">
|
||||
<div
|
||||
|
|
@ -129,14 +203,14 @@ onMounted(() => {
|
|||
<section class="bookmark-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
{{ currentTag ? `标签: ${currentTag}` : '全部书签' }}
|
||||
{{ currentTag ? `标签: ${currentTag}` : (currentFolderId ? '当前文件夹' : '全部书签') }}
|
||||
</h3>
|
||||
<el-button type="primary" @click="handleAddBookmark">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加书签
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
|
||||
<BookmarkList
|
||||
:bookmarks="bookmarks"
|
||||
:loading="bookmarkStore.loading"
|
||||
|
|
@ -158,6 +232,9 @@ onMounted(() => {
|
|||
v-model:visible="showSearch"
|
||||
@close="handleSearchClose"
|
||||
/>
|
||||
|
||||
<!-- 标签管理 -->
|
||||
<TagManager v-model:visible="showTagManager" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
|
|
@ -169,17 +246,25 @@ onMounted(() => {
|
|||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
height: fit-content;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.sidebar-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
margin-bottom: 16px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
|
|
@ -210,6 +295,17 @@ onMounted(() => {
|
|||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tag-color {
|
||||
border-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-color {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
|
|
@ -235,6 +331,18 @@ onMounted(() => {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 16px;
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.recent-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
|
@ -316,4 +424,3 @@ onMounted(() => {
|
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user