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