181 lines
6.9 KiB
Vue
181 lines
6.9 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { ArrowLeft, Search } from '@lucide/vue'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent } from '@/components/ui/card'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { useSearchStore } from '@/stores/searchStore'
|
||
import { registerAllTools, TOOLS_META } from './tools'
|
||
import {
|
||
getAllTools, getTool, searchTools,
|
||
CATEGORY_ORDER, CATEGORY_LABEL, type DevTool
|
||
} from './registry'
|
||
|
||
const searchStore = useSearchStore()
|
||
|
||
registerAllTools()
|
||
|
||
const allTools = getAllTools()
|
||
|
||
// ===== 列表视图:搜索 + 分类筛选 =====
|
||
const query = ref('')
|
||
const category = ref<'all' | DevTool['category']>('all')
|
||
|
||
const filteredTools = computed(() => {
|
||
const list = searchTools(query.value)
|
||
if (category.value === 'all') return list
|
||
return list.filter(t => t.category === category.value)
|
||
})
|
||
|
||
interface ToolGroup {
|
||
category: DevTool['category']
|
||
label: string
|
||
tools: DevTool[]
|
||
}
|
||
|
||
const groupedTools = computed<ToolGroup[]>(() => {
|
||
if (category.value !== 'all') {
|
||
return [{
|
||
category: category.value as DevTool['category'],
|
||
label: CATEGORY_LABEL[category.value as DevTool['category']],
|
||
tools: filteredTools.value
|
||
}]
|
||
}
|
||
const groups: ToolGroup[] = []
|
||
for (const cat of CATEGORY_ORDER) {
|
||
const tools = filteredTools.value.filter(t => t.category === cat)
|
||
if (tools.length > 0) groups.push({ category: cat, label: CATEGORY_LABEL[cat], tools })
|
||
}
|
||
return groups
|
||
})
|
||
|
||
// ===== 详情视图 =====
|
||
const selectedId = ref<string | null>(null)
|
||
const selectedTool = computed<DevTool | null>(() => {
|
||
if (!selectedId.value) return null
|
||
return getTool(selectedId.value) ?? null
|
||
})
|
||
|
||
function openTool(id: string) {
|
||
selectedId.value = id
|
||
}
|
||
|
||
// ===== 全局搜索跳转:工具索引 → 打开对应工具 =====
|
||
onMounted(() => {
|
||
const actions = new Map<number, () => void>()
|
||
TOOLS_META.forEach((t, i) => {
|
||
actions.set(i, () => openTool(t.id))
|
||
})
|
||
searchStore.registerActions('devtools', actions)
|
||
})
|
||
|
||
watch(selectedId, () => {
|
||
query.value = ''
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!--
|
||
# 滚动归属:只有主滚动区滚(2026-09-22 由内滚动改为外滚动)
|
||
|
||
此前本模块的列表页与详情页各自套一层 `ScrollArea`(`h-full overflow-hidden`
|
||
的模块根 + 内部滚动),与 monitor / music / clipboard / 设置页的形态不一致 ——
|
||
那些模块的模块根是 `min-h-full p-*`,整页在 ModuleContainer 的主滚动区里滚。
|
||
两套滚动归属混用会带来一串副作用:Tab 行/工具条要不要固定、内边距该加在
|
||
滚动容器根还是内层、卡片阴影被视口裁(`-mx-3 px-3` 就是在补这个)。
|
||
|
||
现在统一到外滚动:模块根 `min-h-full p-5`,内容自然增高,滚动交给主滚动区。
|
||
p-5 属于**滚动内容**(模块根在主滚动区内部),因此卡片阴影四周都有余量,
|
||
不再需要任何负外边距补偿。
|
||
|
||
工具自身需要限高的地方(正则结果、diff 输出等)仍保留各自的 `max-h-*` 内滚动
|
||
盒子 —— 那是「一块有边界的输出区」,与页面级滚动不是一回事。
|
||
-->
|
||
<div class="min-h-full p-5 flex flex-col">
|
||
<!-- 列表视图 -->
|
||
<template v-if="!selectedTool">
|
||
<div class="flex items-center gap-3 mb-4 flex-wrap">
|
||
<div class="relative flex-1 min-w-[180px] max-w-xs">
|
||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||
<Input v-model="query" placeholder="搜索工具..." class="h-8 pl-8 text-sm" />
|
||
</div>
|
||
<!--
|
||
分类筛选:选中态用主题色软底(与 SegmentedNav 及其余模块的筛选 chip 同一
|
||
口径)。此前写的是 `bg-secondary text-foreground` —— 灰底黑字读起来像
|
||
「禁用」,也是全项目唯一用实心灰表达选中的地方。
|
||
-->
|
||
<Button
|
||
v-for="cat in (['all', ...CATEGORY_ORDER] as const)"
|
||
:key="cat"
|
||
size="sm"
|
||
variant="ghost"
|
||
class="h-7 px-2.5 text-xs"
|
||
:class="
|
||
category === cat
|
||
? 'bg-primary-soft font-medium text-primary-soft-text'
|
||
: 'text-fg-secondary hover:text-foreground'
|
||
"
|
||
@click="category = cat as typeof category"
|
||
>
|
||
{{ cat === 'all' ? '全部' : CATEGORY_LABEL[cat as DevTool['category']] }}
|
||
</Button>
|
||
<span class="text-xs text-muted-foreground ml-auto">{{ (category === 'all' ? allTools : filteredTools).length }} 个工具</span>
|
||
</div>
|
||
|
||
<!-- 空态:flex-1 撑住剩余高度,短页面时也能垂直居中 -->
|
||
<div
|
||
v-if="filteredTools.length === 0"
|
||
class="flex-1 flex items-center justify-center text-muted-foreground text-sm"
|
||
>
|
||
没有找到匹配的工具
|
||
</div>
|
||
|
||
<!-- 按分类分组 -->
|
||
<div v-else>
|
||
<div v-for="group in groupedTools" :key="group.category" class="mb-5">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-xs font-medium text-muted-foreground">{{ group.label }}</span>
|
||
<Separator class="flex-1" />
|
||
</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||
<Card
|
||
v-for="tool in group.tools"
|
||
:key="tool.id"
|
||
class="cursor-pointer transition-all hover:border-primary/50 hover:shadow-sm !py-0 !gap-0"
|
||
@click="openTool(tool.id)"
|
||
>
|
||
<CardContent class="p-4 flex flex-col gap-2">
|
||
<div class="size-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||
<component :is="tool.icon" class="size-5" />
|
||
</div>
|
||
<div class="flex flex-col gap-0.5">
|
||
<span class="text-sm font-medium leading-tight">{{ tool.name }}</span>
|
||
<span class="text-xs text-muted-foreground leading-snug line-clamp-2">{{ tool.description }}</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 工具详情视图 -->
|
||
<template v-else>
|
||
<div class="flex items-center gap-3 mb-4">
|
||
<Button size="sm" variant="ghost" class="gap-1 h-8" @click="selectedId = null">
|
||
<ArrowLeft class="size-4" />
|
||
返回
|
||
</Button>
|
||
<div class="size-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||
<component :is="selectedTool.icon" class="size-4" />
|
||
</div>
|
||
<div class="flex flex-col">
|
||
<span class="text-sm font-medium leading-tight">{{ selectedTool.name }}</span>
|
||
<span class="text-xs text-muted-foreground leading-snug">{{ selectedTool.description }}</span>
|
||
</div>
|
||
</div>
|
||
<component :is="selectedTool.component" />
|
||
</template>
|
||
</div>
|
||
</template> |