You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1578 lines
39 KiB

<script setup lang="ts">
import type { CategoryNode } from '~/components/index/CategoryTreeNode.vue'
import type { CategoryFormMode } from '~/components/index/CategoryFormModal.vue'
import type { CardDetail } from '~/components/index/CardDetailModal.vue'
import { request } from '~/utils/http/factory'
import { cardTypeRegistry, type CardType, type CardItemLike } from '~/config/cardTypes'
definePageMeta({
layout: 'home',
})
const { $toast } = useNuxtApp()
const { isFavorited, toggle: toggleFavorite, syncFromCards } = useFavorite()
const route = useRoute()
const router = useRouter()
const isMobile = useMediaQuery('(max-width: 767.98px)')
const drawerOpen = ref(false)
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail')
// confirmation dialog
const confirmShow = ref(false)
const confirmMsg = ref('')
let confirmAction: (() => void) | null = null
function showConfirm(msg: string, action: () => void) {
confirmMsg.value = msg
confirmAction = action
confirmShow.value = true
}
function onConfirm() {
confirmAction?.()
confirmShow.value = false
}
// ── Server response types ──
interface ServerCategory {
id: string
name: string
slug: string
image: string | null
parentId: string | null
sortOrder: number
count: number
createdAt: Date
updatedAt: Date
children: ServerCategory[]
}
interface ServerCard {
id: number
type: CardType
title: string
description: string | null
aspectRatio: number | null
categoryId: string | null
createdAt: Date
updatedAt: Date
images: { id: number; url: string; sortOrder: number }[]
tags: { id: number; name: string; slug: string }[]
}
interface ServerTag {
id: number
name: string
slug: string
}
// ── Frontend card item (mapped from server) ──
interface CardItem extends CardItemLike {
id: number
}
function mapCard(server: ServerCard): CardItem {
const imgUrls = server.images.map((img) => img.url)
return {
id: server.id,
type: server.type,
image: imgUrls[0],
images: imgUrls.length > 1 ? imgUrls : undefined,
title: server.title,
description: server.description ?? undefined,
tags: server.tags.length > 0 ? server.tags.map((t) => t.name) : undefined,
aspectRatio: server.aspectRatio ?? 0.75,
categoryId: server.categoryId,
createdAt: server.createdAt ? new Date(server.createdAt).toISOString() : undefined,
}
}
function mapCategoryTree(server: ServerCategory[]): CategoryNode[] {
function map(node: ServerCategory): CategoryNode {
return {
id: node.id,
name: node.name,
image: node.image ?? undefined,
count: node.count,
children: node.children.length > 0 ? node.children.map(map) : undefined,
}
}
return server.map(map)
}
// ── Sidebar state ──
const activeToolKey = ref<string>('home')
const activeCategoryId = ref<string>('all')
const searchQuery = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
const categories = ref<CategoryNode[]>([])
const categoriesLoading = ref(true)
async function fetchCategories() {
categoriesLoading.value = true
try {
const raw = await request<{ code: number; data: ServerCategory[] }>('/api/categories')
const tree = mapCategoryTree(raw.data)
// Prepend virtual "未分类" node for cards without a category
const uncategorized: CategoryNode = {
id: '__uncategorized__',
name: '未分类',
}
categories.value = [uncategorized, ...tree]
} catch {
$toast.error('分类加载失败')
} finally {
categoriesLoading.value = false
}
}
const activeCategoryName = computed(() => {
function find(nodes: CategoryNode[]): CategoryNode | null {
for (const n of nodes) {
if (n.id === activeCategoryId.value) return n
if (n.children) {
const r = find(n.children)
if (r) return r
}
}
return null
}
if (activeCategoryId.value === '__uncategorized__') return '未分类'
return find(categories.value)?.name ?? '全部卡片'
})
// ── Tool selection ──
const showTagsModal = ref(false)
const tagList = ref<ServerTag[]>([])
const tagSearch = ref('')
const tagsLoading = ref(false)
async function fetchTags() {
tagsLoading.value = true
try {
const raw = await request<{ code: number; data: { list: ServerTag[] } }>('/api/tags')
tagList.value = raw.data.list
} catch {
$toast.error('标签加载失败')
} finally {
tagsLoading.value = false
}
}
const filteredTagList = computed(() => {
const q = tagSearch.value.trim().toLowerCase()
if (!q) return tagList.value
return tagList.value.filter(
(t) => t.name.toLowerCase().includes(q) || t.slug.toLowerCase().includes(q),
)
})
function onSelectTool(key: string) {
activeToolKey.value = key
switch (key) {
case 'home':
activeCategoryId.value = 'all'
break
case 'collect':
activeCategoryId.value = 'all'
break
case 'tags':
showTagsModal.value = true
fetchTags()
return
case 'search':
activeCategoryId.value = 'all'
searchQuery.value = (route.query.q as string) ?? ''
nextTick(() => searchInputRef.value?.focus())
break
case 'highlights':
$toast.info('高亮即将上线')
break
case 'archive':
$toast.info('归档即将上线')
break
}
}
function onSelectTag(_tagId: number) {
// Future: filter cards by tag
$toast.info('标签筛选即将上线')
}
// ── Category CRUD ──
// Modal state
const catFormVisible = ref(false)
const catFormMode = ref<CategoryFormMode>('create')
const catFormNode = ref<CategoryNode | null>(null)
const catFormParent = ref<CategoryNode | null>(null)
function openCatForm(mode: CategoryFormMode, node: CategoryNode | null, parent?: CategoryNode | null) {
catFormMode.value = mode
catFormNode.value = node
catFormParent.value = parent ?? null
catFormVisible.value = true
}
async function onSelectCategory(id: string) {
if (activeCategoryId.value === id) {
activeCategoryId.value = 'all'
} else {
activeCategoryId.value = id
}
activeToolKey.value = 'home'
if (drawerOpen.value) drawerOpen.value = false
}
function onAddCategory(parentId: string | null) {
const parent = parentId ? findNode(categories.value, parentId) : null
openCatForm('create', null, parent)
}
function findNode(nodes: CategoryNode[], id: string): CategoryNode | null {
for (const n of nodes) {
if (n.id === id) return n
if (n.children) {
const r = findNode(n.children, id)
if (r) return r
}
}
return null
}
function onRenameCategory(id: string) {
if (id === 'all' || id === '__uncategorized__') return
const node = findNode(categories.value, id)
if (!node) return
openCatForm('rename', node)
}
function onDeleteCategory(id: string) {
if (id === 'all' || id === '__uncategorized__') return
const node = findNode(categories.value, id)
if (!node) return
openCatForm('delete', node)
}
function onMoveCategory(id: string) {
if (id === 'all' || id === '__uncategorized__') return
const node = findNode(categories.value, id)
if (!node) return
openCatForm('move', node)
}
function onChangeCover(id: string) {
if (id === 'all' || id === '__uncategorized__') return
const node = findNode(categories.value, id)
if (!node) return
openCatForm('cover', node)
}
async function onCatFormSubmit(data: {
mode: CategoryFormMode
nodeId: string | null
name?: string
slug?: string
image?: string
targetParentId?: string | null
}) {
try {
switch (data.mode) {
case 'create': {
await request('/api/categories', {
method: 'POST',
body: {
name: data.name!,
slug: data.slug!,
parentId: data.targetParentId || null,
},
})
$toast.success(`已创建分类「${data.name}`)
break
}
case 'rename': {
await request(`/api/categories/${data.nodeId}`, {
method: 'PUT',
body: {
name: data.name!,
...(data.slug ? { slug: data.slug } : {}),
},
})
$toast.success('已重命名')
break
}
case 'cover': {
await request(`/api/categories/${data.nodeId}`, {
method: 'PUT',
body: { image: data.image! },
})
$toast.success('封面已更新')
break
}
case 'delete': {
// Delete children first (bottom-up)
const node = data.nodeId ? findNode(categories.value, data.nodeId) : null
if (node?.children) {
for (const child of node.children) {
await request(`/api/categories/${child.id}`, { method: 'DELETE' })
}
}
await request(`/api/categories/${data.nodeId}`, { method: 'DELETE' })
if (activeCategoryId.value === data.nodeId) activeCategoryId.value = 'all'
$toast.success('已删除分类')
break
}
case 'move': {
await request(`/api/categories/${data.nodeId}`, {
method: 'PUT',
body: { parentId: data.targetParentId || null },
})
$toast.success('已移动分类')
break
}
}
catFormVisible.value = false
await fetchCategories()
} catch (e: any) {
$toast.error(e.data.statusMessage || e.message || e.toString() || '操作失败')
}
}
// ── Card modals ──
const showDetailModal = ref(false)
const detailCard = ref<CardDetail | null>(null)
const detailSize = ref<'default' | 'expanded'>('default')
const showCardFormModal = ref(false)
const cardFormMode = ref<'create' | 'edit'>('create')
const cardFormEditData = ref<CardDetail | null>(null)
function onCardExpand(card: CardItem) {
detailCard.value = {
id: card.id,
type: card.type,
image: card.image,
images: card.images,
title: card.title,
description: card.description,
tags: card.tags,
aspectRatio: card.aspectRatio,
categoryId: card.categoryId,
createdAt: card.createdAt,
}
detailSize.value = 'expanded'
showDetailModal.value = true
}
function onCardEdit(card: CardDetail) {
cardFormMode.value = 'edit'
cardFormEditData.value = card
showCardFormModal.value = true
showDetailModal.value = false
}
async function onCardDelete(id: number) {
showConfirm('确定删除这张卡片吗?', async () => {
try {
await request(`/api/cards/${id}`, { method: 'DELETE' })
$toast.success('已删除卡片')
showDetailModal.value = false
// Refresh category counts
fetchCategories()
// Reload cards
page.value = 1
hasMore.value = true
allItems.value = []
columns.value = Array.from({ length: columnCount.value }, () => [])
columnHeights.value = new Array(columnCount.value).fill(0)
initLoading.value = true
loadMore()
} catch {
$toast.error('删除失败')
}
})
}
function onNewCard() {
cardFormMode.value = 'create'
cardFormEditData.value = null
showCardFormModal.value = true
}
function onCardSaved() {
$toast.success(cardFormMode.value === 'create' ? '卡片已创建' : '卡片已更新')
fetchCategories()
resetAndReload()
}
// ── Drag & Drop to category ──
let dragCardId: number | null = null
function onCardDragStart(e: DragEvent, item: CardItem) {
dragCardId = item.id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('application/x-card-id', String(item.id))
}
const el = e.currentTarget as HTMLElement
// build a blurred ghost clone for the drag image
const MAX_DRAG_WIDTH = 280
const ghost = el.cloneNode(true) as HTMLElement
const rect = el.getBoundingClientRect()
const ratio = rect.width > MAX_DRAG_WIDTH ? MAX_DRAG_WIDTH / rect.width : 1
ghost.style.position = 'fixed'
ghost.style.top = '-9999px'
ghost.style.left = '-9999px'
ghost.style.width = (ratio < 1 ? MAX_DRAG_WIDTH : rect.width) + 'px'
ghost.style.opacity = '0.55'
ghost.style.filter = 'blur(4px)'
ghost.style.animation = 'none'
ghost.style.pointerEvents = 'none'
document.body.appendChild(ghost)
const offsetX = (e.clientX - rect.left) * ratio
const offsetY = (e.clientY - rect.top) * ratio
e.dataTransfer!.setDragImage(ghost, offsetX, offsetY)
// fade the original card after browser snaps the drag image
requestAnimationFrame(() => {
el.classList.add('dragging')
// ghost can be removed — browser holds a reference
ghost.remove()
})
}
function onCardDragEnd(e: DragEvent) {
dragCardId = null
const el = e.currentTarget as HTMLElement
el.classList.remove('dragging')
}
async function onCardDropToCategory(categoryId: string | null, categoryName: string) {
if (dragCardId === null) return
try {
await request(`/api/cards/${dragCardId}`, {
method: 'PUT',
body: { categoryId },
})
$toast.success(`已移至「${categoryName}`)
fetchCategories()
resetAndReload()
} catch {
$toast.error('移动失败')
} finally {
dragCardId = null
}
}
// ── Right panel waterfall ──
const sentinel = ref<HTMLElement | null>(null)
const mainRef = ref<HTMLElement | null>(null)
const allItems = ref<CardItem[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const initLoading = ref(true)
const columnCount = ref(1)
const columns = ref<CardItem[][]>([[]])
const columnHeights = ref<number[]>([0])
function containerWidth(): number {
return mainRef.value?.clientWidth ?? 800
}
function getColumnWidth(): number {
const padding = 32
const w = containerWidth() - padding
const gap = 8
return (w - gap * (columnCount.value - 1)) / columnCount.value
}
function estimateCardHeight(item: CardItem): number {
const colWidth = getColumnWidth()
if (item.type === 'text') {
const textWidth = colWidth - 48
const charsPerLine = Math.max(1, Math.floor(textWidth / 13.5))
const charCount = (item.description || '').length
const lines = Math.ceil(charCount / charsPerLine)
return 109 + lines * 24
}
return colWidth / item.aspectRatio
}
function addToShortestColumn(item: CardItem) {
let minIdx = 0
for (let i = 1; i < columnCount.value; i++) {
if (columnHeights.value[i]! < columnHeights.value[minIdx]!) minIdx = i
}
columns.value[minIdx]!.push(item)
columnHeights.value[minIdx]! += estimateCardHeight(item)
}
function distributeAll() {
const n = columnCount.value
columns.value = Array.from({ length: n }, () => [])
columnHeights.value = new Array(n).fill(0)
for (const item of allItems.value) addToShortestColumn(item)
}
function decideColumnCount(): number {
if (isMobile.value) return 1
const w = containerWidth()
if (w < 1100) return 2
return 3
}
let resizeTimer: ReturnType<typeof setTimeout> | null = null
function onContainerResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
const n = decideColumnCount()
if (n !== columnCount.value) {
columnCount.value = n
distributeAll()
}
}, 120)
}
function removeCardFromList(cardId: number) {
allItems.value = allItems.value.filter((item) => item.id !== cardId)
distributeAll()
}
async function handleToggleFavorite(cardId: number) {
const wasFavorited = isFavorited(cardId)
const result = await toggleFavorite(cardId)
// When unfavoriting on the collect page, remove the card from the list immediately
if (wasFavorited && !result && activeToolKey.value === 'collect') {
removeCardFromList(cardId)
}
}
function onDetailFavoriteToggled(cardId: number, favorited: boolean) {
// When unfavoriting from the detail modal on the collect page, remove the card
if (!favorited && activeToolKey.value === 'collect') {
removeCardFromList(cardId)
}
}
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const query: Record<string, string | number> = {
page: page.value,
pageSize: 12,
}
if (activeCategoryId.value !== 'all') {
query.categoryId = activeCategoryId.value
}
// favorites mode
if (activeToolKey.value === 'collect') {
query.favorited = 1
}
// search mode — skip category / favorite filters
if (activeToolKey.value === 'search') {
const q = (route.query.q as string)?.trim()
if (q) query.q = q
else {
// No query — don't load anything
loading.value = false
initLoading.value = false
return
}
} else {
// '__uncategorized__' will be passed as-is to filter null categories
const q = (route.query.q as string)?.trim()
if (q) query.q = q
}
const raw = await $fetch<{
code: number
data: {
items: ServerCard[]
hasMore: boolean
total: number
}
}>('/api/cards', { query })
const result = raw.data
// Sync favorite status from server
syncFromCards(result.items as Array<{ id: number; isFavorited?: boolean }>)
const mapped = result.items.map(mapCard)
for (const item of mapped) {
allItems.value.push(item)
addToShortestColumn(item)
}
hasMore.value = result.hasMore
page.value++
} catch {
$toast.error('加载失败,请稍后重试')
} finally {
loading.value = false
initLoading.value = false
}
}
// Reset when category switches
watch(activeCategoryId, () => {
resetAndReload()
})
// Reset when search query changes
watch(() => route.query.q, () => {
resetAndReload()
})
// Reset when tool changes (e.g. collect <-> home)
watch(activeToolKey, () => {
resetAndReload()
})
// Debounced sync from search input to route.query.q
let searchDebounce: ReturnType<typeof setTimeout> | null = null
watch(searchQuery, (val) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => {
const trimmed = val.trim()
if (trimmed) {
router.replace({ query: { ...route.query, q: trimmed } })
} else if (route.query.q) {
const { q, ...rest } = route.query
router.replace({ query: rest })
}
}, 300)
})
function resetAndReload() {
page.value = 1
hasMore.value = true
allItems.value = []
columns.value = Array.from({ length: columnCount.value }, () => [])
columnHeights.value = new Array(columnCount.value).fill(0)
initLoading.value = true
loadMore()
}
watch(isMobile, () => {
const n = decideColumnCount()
if (n !== columnCount.value) {
columnCount.value = n
distributeAll()
}
})
let observer: IntersectionObserver | null = null
let resizeObserver: ResizeObserver | null = null
watch(sentinel, (el) => {
observer?.disconnect()
if (!el) return
observer = new IntersectionObserver(
([entry]) => {
if (entry!.isIntersecting) loadMore()
},
{ rootMargin: '320px' },
)
observer.observe(el)
})
onMounted(() => {
if (mainRef.value) {
columnCount.value = decideColumnCount()
distributeAll()
resizeObserver = new ResizeObserver(onContainerResize)
resizeObserver.observe(mainRef.value)
}
fetchCategories()
resetAndReload()
})
onUnmounted(() => {
observer?.disconnect()
resizeObserver?.disconnect()
if (resizeTimer) clearTimeout(resizeTimer)
})
</script>
<template>
<div class="home-layout">
<IndexLeftSidebar
v-if="sidebarMode === 'rail'"
class="home-sidebar"
:mode="sidebarMode"
:categories="categories"
:active-tool-key="activeToolKey"
:active-category-id="activeCategoryId"
@select-tool="onSelectTool"
@select-category="onSelectCategory"
@add-category="onAddCategory"
@rename-category="onRenameCategory"
@delete-category="onDeleteCategory"
@move-category="onMoveCategory"
@change-cover="onChangeCover"
@drop-card="onCardDropToCategory"
/>
<IndexLeftSidebar
v-else
:mode="sidebarMode"
:drawer-open="drawerOpen"
:categories="categories"
:active-tool-key="activeToolKey"
:active-category-id="activeCategoryId"
@update:drawer-open="(v: boolean) => drawerOpen = v"
@select-tool="onSelectTool"
@select-category="onSelectCategory"
@add-category="onAddCategory"
@rename-category="onRenameCategory"
@delete-category="onDeleteCategory"
@move-category="onMoveCategory"
@change-cover="onChangeCover"
@drop-card="onCardDropToCategory"
/>
<main ref="mainRef" class="home-main">
<!-- Mobile drawer trigger -->
<div v-if="isMobile" class="mobile-bar">
<button
type="button"
class="drawer-trigger"
aria-label="打开分类抽屉"
@click="drawerOpen = true"
>
<Icon name="lucide:menu" />
<span>分类</span>
</button>
<span class="category-chip">
<template v-if="activeToolKey === 'search'">搜索</template>
<template v-else>{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</template>
</span>
</div>
<!-- Search section -->
<section v-if="activeToolKey === 'search'" class="search-section">
<div class="search-header">
<h2 class="search-heading">搜索卡片</h2>
<p class="search-sub">按标题、描述查找卡片</p>
</div>
<div class="search-bar">
<Icon name="lucide:search" class="search-bar-icon" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="search"
class="search-bar-input"
placeholder="输入关键词…"
>
<button
v-if="searchQuery"
type="button"
class="search-bar-clear"
@click="searchQuery = ''"
>
<Icon name="lucide:x" />
</button>
</div>
<p
v-if="route.query.q && !initLoading && allItems.length > 0"
class="search-count"
>
找到 <strong>{{ allItems.length }}</strong> 条结果
</p>
</section>
<!-- Category heading -->
<h2 v-else-if="!isMobile" class="category-heading">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</h2>
<div class="masonry">
<div
v-for="(col, ci) in columns"
:key="ci"
class="masonry-col"
>
<template v-for="(item, ri) in col" :key="item.id">
<div
class="card-reveal"
:style="{ '--enter-delay': `${ci * 80 + ri * 60}ms` }"
draggable="true"
@dragstart="onCardDragStart($event, item)"
@dragend="onCardDragEnd($event)"
>
<!-- Hover actions -->
<div class="card-actions">
<button
type="button"
class="card-action-btn"
:class="{ 'card-action-faved': isFavorited(item.id) }"
:title="isFavorited(item.id) ? '取消收藏' : '收藏'"
@click.stop="handleToggleFavorite(item.id)"
>
<Icon :name="isFavorited(item.id) ? 'ph:star-fill' : 'ph:star'" />
</button>
<button
type="button"
class="card-action-btn"
title="展开"
@click.stop="onCardExpand(item)"
>
<Icon name="lucide:maximize-2" />
</button>
<button
type="button"
class="card-action-btn"
title="编辑"
@click.stop="onCardEdit({ id: item.id, type: item.type, title: item.title, description: item.description, image: item.image, images: item.images, tags: item.tags, aspectRatio: item.aspectRatio, categoryId: item.categoryId, createdAt: item.createdAt })"
>
<Icon name="lucide:pencil" />
</button>
<button
type="button"
class="card-action-btn card-action-delete"
title="删除"
@click.stop="onCardDelete(item.id)"
>
<Icon name="lucide:trash-2" />
</button>
</div>
<component
:is="cardTypeRegistry[item.type].component"
v-bind="cardTypeRegistry[item.type].mapProps(item)"
/>
</div>
</template>
</div>
</div>
<!-- Empty state -->
<div
v-if="!initLoading && allItems.length === 0"
class="empty-state"
>
<Icon name="lucide:inbox" class="empty-icon" />
<h3 v-if="activeToolKey === 'search' && !route.query.q">搜索卡片</h3>
<h3 v-else>还没有内容</h3>
<p v-if="activeToolKey === 'search' && !route.query.q">输入关键词搜索卡片标题和描述</p>
<p v-else-if="activeCategoryId !== 'all'">当前分类下暂无卡片</p>
<p v-else-if="route.query.q">没有找到匹配「{{ route.query.q }}」的卡片</p>
<p v-else>点击右下角 + 按钮创建第一张卡片</p>
</div>
<div ref="sentinel" class="sentinel">
<template v-if="initLoading || loading">
<div class="loading-bars">
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" />
</div>
</template>
<span v-else-if="hasMore && allItems.length > 0" class="sentinel-text">继续滚动查看更多</span>
<span v-else-if="allItems.length > 0" class="sentinel-text end">—— 已经到底了 ——</span>
</div>
<!-- Floating add button -->
<button type="button" class="fab-add" aria-label="新增卡片" @click="onNewCard">
<Icon name="lucide:plus" />
</button>
</main>
<!-- Tags Modal -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="showTagsModal" class="modal-overlay" @click.self="showTagsModal = false">
<div class="modal-panel">
<div class="modal-header">
<h2 class="modal-title">标签</h2>
<button type="button" class="modal-close" @click="showTagsModal = false">
<Icon name="lucide:x" />
</button>
</div>
<div class="modal-search">
<Icon name="lucide:search" class="modal-search-icon" />
<input
v-model="tagSearch"
type="text"
class="modal-search-input"
placeholder="搜索标签..."
/>
</div>
<div class="modal-body">
<template v-if="tagsLoading">
<div class="modal-loading">加载中...</div>
</template>
<template v-else-if="filteredTagList.length === 0">
<div class="modal-empty">
<Icon name="lucide:tag" class="modal-empty-icon" />
<span>{{ tagSearch ? '没有匹配的标签' : '还没有标签' }}</span>
</div>
</template>
<template v-else>
<button
v-for="tag in filteredTagList"
:key="tag.id"
type="button"
class="modal-tag-item"
@click="onSelectTag(tag.id)"
>
<Icon name="lucide:hash" class="modal-tag-icon" />
<span class="modal-tag-name">{{ tag.name }}</span>
<span class="modal-tag-count">{{ tag.slug }}</span>
</button>
</template>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Card Detail Modal -->
<IndexCardDetailModal
:visible="showDetailModal"
:card="detailCard"
:categories="categories"
:size="detailSize"
@update:visible="(v) => { showDetailModal = v; if (!v) detailSize = 'default' }"
@favorite-toggled="onDetailFavoriteToggled"
/>
<!-- Card Form Modal -->
<IndexCardFormModal
:visible="showCardFormModal"
:mode="cardFormMode"
:card="cardFormEditData"
:categories="categories"
@update:visible="(v) => showCardFormModal = v"
@saved="onCardSaved"
/>
<!-- Category Form Modal -->
<IndexCategoryFormModal
:visible="catFormVisible"
:mode="catFormMode"
:node="catFormNode"
:parent-node="catFormParent"
:categories="categories"
@update:visible="(v) => catFormVisible = v"
@submit="onCatFormSubmit"
/>
<!-- Confirm Dialog -->
<BoDialog v-model:show="confirmShow" :mask-can-close="true">
<div class="rounded-lg bg-white p-6 shadow-xl border border-[#e6dfd8] confirm-dialog">
<h3 class="text-[16px] font-medium text-[#141413]">确认操作</h3>
<p class="mt-3 text-[14px] text-[#3d3d3a]">{{ confirmMsg }}</p>
<div class="mt-6 flex justify-end gap-2">
<button class="px-4 py-2 bg-[#faf9f5] border border-[#e6dfd8] rounded-lg text-[14px] hover:bg-white transition-colors" @click="confirmShow = false">取消</button>
<button class="px-4 py-2 bg-[#cc785c] text-white rounded-lg text-[14px] hover:bg-[#a9583e] transition-colors" @click="onConfirm">确定</button>
</div>
</div>
</BoDialog>
</div>
</template>
<style scoped>
.home-layout {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 240px 1fr;
align-items: start;
gap: 0;
}
.home-sidebar {
position: sticky;
top: 64px;
height: calc(100vh - 64px);
align-self: start;
}
.home-main {
min-width: 0;
padding: 12px 16px 40px;
display: flex;
flex-direction: column;
}
/* ── Category heading ── */
.category-heading {
font-family: var(--font-display);
font-size: 22px;
font-weight: 500;
color: var(--color-ink);
margin: 0 0 12px 0;
letter-spacing: -0.3px;
}
/* ── Search section ── */
.search-section {
margin-bottom: 8px;
}
.search-header {
margin-bottom: 16px;
}
.search-heading {
font-family: var(--font-display);
font-size: 28px;
font-weight: 400;
line-height: 1.2;
letter-spacing: -0.3px;
color: var(--color-ink);
margin: 0 0 6px 0;
}
.search-sub {
font-family: var(--font-body);
font-size: 14px;
color: var(--color-muted);
margin: 0;
}
.search-bar {
position: relative;
display: flex;
align-items: center;
max-width: 560px;
height: 48px;
margin-bottom: 12px;
background: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: 12px;
padding: 0 16px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.search-bar:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px rgba(204, 120, 92, 0.12);
}
.search-bar-icon {
width: 20px;
height: 20px;
color: var(--color-muted);
flex-shrink: 0;
}
.search-bar-input {
flex: 1;
height: 100%;
border: none;
background: transparent;
outline: none;
padding: 0 12px;
font-family: var(--font-body);
font-size: 16px;
color: var(--color-ink);
}
.search-bar-input::placeholder {
color: var(--color-muted-soft);
}
.search-bar-input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
.search-bar-clear {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--color-muted);
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease;
}
.search-bar-clear:hover {
background: var(--color-surface-soft);
color: var(--color-ink);
}
.search-count {
font-family: var(--font-body);
font-size: 13px;
color: var(--color-muted);
margin: 0 0 4px 0;
}
.search-count strong {
font-weight: 500;
color: var(--color-ink);
}
/* ── Mobile bar ── */
.mobile-bar {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--color-hairline);
}
/* ── Masonry ── */
.masonry {
display: flex;
gap: 8px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-reveal {
animation: card-enter 0.6s var(--ease-out-expo) both;
animation-delay: var(--enter-delay, 0ms);
cursor: pointer;
position: relative;
transition: opacity 0.2s ease;
}
.card-reveal.dragging {
opacity: 0.3;
pointer-events: none;
}
.card-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
z-index: 2;
opacity: 0;
transition: opacity 0.18s ease;
}
.card-reveal:hover .card-actions {
opacity: 1;
}
.card-action-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 5px;
background: rgba(20, 20, 19, 0.45);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
transition: background 0.12s ease;
}
.card-action-btn :deep(svg) {
width: 12px;
height: 12px;
}
.card-action-btn:hover {
background: rgba(20, 20, 19, 0.7);
color: #fff;
}
.card-action-delete:hover {
background: rgba(198, 69, 69, 0.8);
}
.card-action-faved {
color: var(--color-accent-amber);
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Sentinel ── */
.sentinel {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0 24px;
min-height: 56px;
}
.sentinel-text {
font-size: 13px;
color: var(--color-muted-soft);
}
.sentinel-text.end {
color: var(--color-hairline);
}
.loading-bars {
display: flex;
gap: 5px;
align-items: center;
}
.bar {
width: 4px;
height: 18px;
background: var(--color-primary);
border-radius: 2px;
opacity: 0.5;
animation: pulse 0.9s ease-in-out infinite;
}
/* ── Empty state ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 64px 24px;
text-align: center;
}
.empty-icon {
width: 48px;
height: 48px;
color: var(--color-hairline);
margin-bottom: 4px;
}
.empty-state h3 {
font-family: var(--font-display);
font-size: 18px;
font-weight: 400;
color: var(--color-muted);
margin: 0;
}
.empty-state p {
font-size: 13px;
color: var(--color-muted-soft);
margin: 0;
max-width: 300px;
}
/* ── Floating add button ── */
.fab-add {
position: fixed;
bottom: 32px;
right: 32px;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--color-primary);
border: none;
color: var(--color-on-primary, #fff);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(204, 120, 92, 0.35);
transition: transform 0.2s var(--ease-out-expo), box-shadow 0.2s ease;
z-index: 40;
}
.fab-add :deep(svg) {
width: 22px;
height: 22px;
}
.fab-add:hover {
transform: scale(1.08);
box-shadow: 0 6px 24px rgba(204, 120, 92, 0.45);
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scaleY(0.6); }
50% { opacity: 0.8; transform: scaleY(1); }
}
/* ── Responsive (DESIGN.md breakpoints) ── */
@media (max-width: 767.98px) {
.home-layout {
grid-template-columns: 1fr;
}
.home-sidebar {
display: none;
}
.home-main {
padding: 8px 12px 24px;
}
}
@media (min-width: 768px) and (max-width: 1023.98px) {
.home-layout {
grid-template-columns: 240px 1fr;
}
.home-main {
padding: 20px 16px 32px;
}
}
.drawer-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 16px;
border-radius: var(--rounded-md, 8px);
background: transparent;
border: 1px solid var(--color-hairline);
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--color-ink);
cursor: pointer;
min-width: 44px;
transition: border-color 0.15s ease, color 0.15s ease;
}
.drawer-trigger:hover {
border-color: var(--color-ink);
}
.drawer-trigger :deep(svg) {
width: 18px;
height: 18px;
}
.category-chip {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 14px;
border-radius: 9999px;
background: var(--color-surface-card);
color: var(--color-ink);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
}
/* ── Tags Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
}
.modal-panel {
width: 90vw;
max-width: 420px;
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--color-surface-default, #fff);
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
}
.modal-title {
font-family: var(--font-display);
font-size: 20px;
font-weight: 500;
color: var(--color-ink);
margin: 0;
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: none;
border: none;
cursor: pointer;
color: var(--color-muted);
transition: all 0.12s ease;
}
.modal-close :deep(svg) {
width: 18px;
height: 18px;
}
.modal-close:hover {
background: var(--color-surface-soft);
color: var(--color-ink);
}
.modal-search {
position: relative;
margin: 0 20px 8px;
}
.modal-search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--color-muted-soft);
}
.modal-search-input {
width: 100%;
height: 40px;
padding: 0 12px 0 36px;
border-radius: 10px;
border: 1px solid var(--color-hairline);
background: var(--color-surface-soft);
font-family: var(--font-body);
font-size: 13px;
color: var(--color-ink);
outline: none;
transition: border-color 0.15s ease;
}
.modal-search-input:focus {
border-color: var(--color-primary);
}
.modal-search-input::placeholder {
color: var(--color-muted-soft);
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 8px 12px 16px;
}
.modal-loading,
.modal-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 32px 16px;
color: var(--color-muted-soft);
font-size: 13px;
}
.modal-empty-icon {
width: 28px;
height: 28px;
opacity: 0.4;
}
.modal-tag-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border-radius: 10px;
background: none;
border: none;
cursor: pointer;
font-family: var(--font-body);
text-align: left;
transition: background 0.12s ease;
}
.modal-tag-item:hover {
background: var(--color-surface-soft);
}
.modal-tag-icon {
width: 16px;
height: 16px;
color: var(--color-muted);
flex-shrink: 0;
}
.modal-tag-name {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--color-ink);
}
.modal-tag-count {
font-size: 11px;
color: var(--color-muted-soft);
font-family: var(--font-mono, monospace);
}
/* ── Modal transition ── */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-active .modal-panel,
.modal-fade-leave-active .modal-panel {
transition: transform 0.2s var(--ease-out-expo);
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .modal-panel {
transform: scale(0.95) translateY(8px);
}
.modal-fade-leave-to .modal-panel {
transform: scale(0.95) translateY(8px);
}
</style>