This commit is contained in:
zpc 2025-12-25 23:05:20 +08:00
parent 22647099c2
commit e8816c1ffb
12 changed files with 1146 additions and 90 deletions

View File

@ -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
- 更新说明:完成前端重构

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

@ -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[]
}

View File

@ -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,7 +203,7 @@ 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>
@ -158,6 +232,9 @@ onMounted(() => {
v-model:visible="showSearch"
@close="handleSearchClose"
/>
<!-- 标签管理 -->
<TagManager v-model:visible="showTagManager" />
</AppLayout>
</template>
@ -169,16 +246,24 @@ 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;
@ -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>