Merge branches 'main' and 'main' of https://github.com/kjqwer/prompt

This commit is contained in:
2025-12-12 16:08:43 +08:00
17 changed files with 1069 additions and 116 deletions
+157 -48
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, nextTick } from 'vue'
import PromptEditor from './components/PromptEditor.vue' import PromptEditor from './components/PromptEditor.vue'
import PromptManager from './components/PromptManager.vue' import PromptManager from './components/PromptManager.vue'
import PresetManager from './components/PresetManager.vue' import PresetManager from './components/PresetManager.vue'
@@ -9,6 +9,14 @@ import GridBackground from './components/Background/GridBackground.vue'
import DevtoolsBanner from './components/DevtoolsBanner.vue' import DevtoolsBanner from './components/DevtoolsBanner.vue'
import { usePromptStore } from './stores/promptStore' import { usePromptStore } from './stores/promptStore'
// Icons
import IconEditor from './components/icons/IconEditor.vue'
import IconPresets from './components/icons/IconPresets.vue'
import IconManager from './components/icons/IconManager.vue'
import IconTheme from './components/icons/IconTheme.vue'
import IconBackground from './components/icons/IconBackground.vue'
import IconGithub from './components/icons/IconGithub.vue'
const currentView = ref<'editor' | 'manager' | 'presets'>('editor') const currentView = ref<'editor' | 'manager' | 'presets'>('editor')
const isDark = ref(false) const isDark = ref(false)
const bgModes = ['particles', 'grid', 'gradient', 'off'] as const const bgModes = ['particles', 'grid', 'gradient', 'off'] as const
@@ -39,10 +47,61 @@ onMounted(() => {
} }
}) })
function toggleTheme() { function toggleTheme(event: MouseEvent) {
const isAppearanceTransition = 'startViewTransition' in document
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!isAppearanceTransition) {
isDark.value = !isDark.value isDark.value = !isDark.value
localStorage.setItem('theme', isDark.value ? 'dark' : 'light') localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
updateTheme() updateTheme()
return
}
const willBeDark = !isDark.value
if (willBeDark) {
document.documentElement.classList.add('theme-transition-reverse')
}
const x = event.clientX
const y = event.clientY
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)
const transition = document.startViewTransition(async () => {
isDark.value = willBeDark
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
updateTheme()
await nextTick()
})
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`
]
const isReverse = willBeDark
document.documentElement.animate(
{
clipPath: isReverse ? [...clipPath].reverse() : clipPath,
},
{
duration: 400,
easing: 'ease-out',
pseudoElement: isReverse ? '::view-transition-old(root)' : '::view-transition-new(root)',
fill: 'forwards',
}
)
})
transition.finished.then(() => {
document.documentElement.classList.remove('theme-transition-reverse')
})
} }
function updateTheme() { function updateTheme() {
@@ -53,7 +112,12 @@ function switchView(view: 'editor' | 'manager' | 'presets') {
currentView.value = view currentView.value = view
} }
function cycleBackground() { function cycleBackground(event?: MouseEvent) {
const isAppearanceTransition = 'startViewTransition' in document
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches
&& event instanceof MouseEvent
const updateState = () => {
const currentIndex = bgModes.indexOf(currentBgMode.value) const currentIndex = bgModes.indexOf(currentBgMode.value)
const nextIndex = (currentIndex + 1) % bgModes.length const nextIndex = (currentIndex + 1) % bgModes.length
const nextMode = bgModes[nextIndex] const nextMode = bgModes[nextIndex]
@@ -63,6 +127,70 @@ function cycleBackground() {
} }
} }
if (!isAppearanceTransition) {
updateState()
return
}
const x = event.clientX
const y = event.clientY
const transition = document.startViewTransition(async () => {
updateState()
await nextTick()
})
transition.ready.then(() => {
const effects = ['circle', 'vertical', 'horizontal', 'diamond']
const effect = effects[Math.floor(Math.random() * effects.length)]
let clipPath: string[] = []
if (effect === 'circle') {
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)
clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`
]
} else if (effect === 'vertical') {
clipPath = [
'inset(0 0 100% 0)',
'inset(0 0 0 0)'
]
} else if (effect === 'horizontal') {
clipPath = [
'inset(0 100% 0 0)',
'inset(0 0 0 0)'
]
} else if (effect === 'diamond') {
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
) * 1.5 // Multiply to ensure coverage for diamond shape
clipPath = [
`polygon(${x}px ${y}px, ${x}px ${y}px, ${x}px ${y}px, ${x}px ${y}px)`,
`polygon(${x}px ${y - endRadius}px, ${x + endRadius}px ${y}px, ${x}px ${y + endRadius}px, ${x - endRadius}px ${y}px)`
]
}
document.documentElement.animate(
{
clipPath: clipPath,
},
{
duration: 1500,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
pseudoElement: '::view-transition-new(root)',
fill: 'forwards',
}
)
})
}
const bgModeLabel = computed(() => { const bgModeLabel = computed(() => {
switch (currentBgMode.value) { switch (currentBgMode.value) {
case 'particles': return '粒子特效' case 'particles': return '粒子特效'
@@ -92,9 +220,7 @@ const bgModeLabel = computed(() => {
title="打开 GitHub 仓库" title="打开 GitHub 仓库"
> >
<!-- GitHub 标志 --> <!-- GitHub 标志 -->
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> <IconGithub />
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.49.5.09.682-.217.682-.483 0-.237-.009-.868-.013-1.705-2.782.604-3.37-1.342-3.37-1.342-.455-1.157-1.111-1.466-1.111-1.466-.908-.62.069-.607.069-.607 1.003.07 1.53 1.03 1.53 1.03.892 1.528 2.341 1.087 2.91.832.092-.646.35-1.087.637-1.338-2.221-.253-4.558-1.11-4.558-4.941 0-1.091.39-1.984 1.029-2.682-.104-.254-.446-1.274.098-2.656 0 0 .84-.269 2.753 1.025.798-.222 1.653-.333 2.504-.337.85.004 1.706.115 2.504.337 1.911-1.294 2.75-1.025 2.75-1.025.546 1.382.203 2.402.1 2.656.64.698 1.028 1.591 1.028 2.682 0 3.84-2.34 4.685-4.566 4.934.359.309.679.919.679 1.853 0 1.337-.012 2.415-.012 2.744 0 .268.18.577.688.479C19.137 20.163 22 16.416 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
<span class="app-title">提示词编辑器</span> <span class="app-title">提示词编辑器</span>
</a> </a>
</div> </div>
@@ -105,10 +231,7 @@ const bgModeLabel = computed(() => {
:class="{ active: currentView === 'editor' }" :class="{ active: currentView === 'editor' }"
@click="switchView('editor')" @click="switchView('editor')"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <IconEditor width="16" height="16" />
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>编辑器</span> <span>编辑器</span>
</button> </button>
<button <button
@@ -116,11 +239,7 @@ const bgModeLabel = computed(() => {
:class="{ active: currentView === 'presets' }" :class="{ active: currentView === 'presets' }"
@click="switchView('presets')" @click="switchView('presets')"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <IconPresets width="16" height="16" />
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" stroke="currentColor" stroke-width="2"/>
<polyline points="17,21 17,13 7,13 7,21" stroke="currentColor" stroke-width="2"/>
<polyline points="7,3 7,8 15,8" stroke="currentColor" stroke-width="2"/>
</svg>
<span>预设管理</span> <span>预设管理</span>
</button> </button>
<button <button
@@ -128,46 +247,17 @@ const bgModeLabel = computed(() => {
:class="{ active: currentView === 'manager' }" :class="{ active: currentView === 'manager' }"
@click="switchView('manager')" @click="switchView('manager')"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <IconManager width="16" height="16" />
<path d="M3 3h18v18H3zM9 9h6v6H9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>词库管理</span> <span>词库管理</span>
</button> </button>
</nav> </nav>
<div class="header-right"> <div class="header-right">
<button class="theme-toggle" @click="toggleTheme" title="切换主题"> <button class="theme-toggle" @click="toggleTheme" title="切换主题">
<svg v-if="!isDark" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <IconTheme :is-dark="isDark" />
<circle cx="12" cy="12" r="5" stroke="currentColor" stroke-width="2"/>
<path d="m12 1 0 2m0 18 0 2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12l2 0m18 0 2 0M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button> </button>
<button class="bg-toggle" :class="{ active: currentBgMode !== 'off' }" @click="cycleBackground" :title="bgModeLabel"> <button class="bg-toggle" :class="{ active: currentBgMode !== 'off' }" @click="cycleBackground" :title="bgModeLabel">
<!-- Particles Icon --> <IconBackground :mode="currentBgMode" />
<svg v-if="currentBgMode === 'particles'" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="6" cy="12" r="1.5" fill="currentColor"/>
<circle cx="12" cy="9" r="1.5" fill="currentColor"/>
<circle cx="18" cy="13" r="1.5" fill="currentColor"/>
<path d="M4 16c4-2 8-2 12 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<!-- Grid Icon -->
<svg v-else-if="currentBgMode === 'grid'" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3h18v18H3z" stroke="currentColor" stroke-width="2"/>
<path d="M3 9h18M3 15h18M9 3v18M15 3v18" stroke="currentColor" stroke-width="2"/>
</svg>
<!-- Gradient Icon -->
<svg v-else-if="currentBgMode === 'gradient'" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="M8 12s1.5-2 4-2 4 2 4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<!-- Off Icon -->
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 5l14 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="6" stroke="currentColor" stroke-width="2"/>
</svg>
</button> </button>
</div> </div>
</div> </div>
@@ -185,7 +275,26 @@ const bgModeLabel = computed(() => {
</template> </template>
<style> <style>
/* 全局样式重置和变量定义 */ ::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 9999;
}
.theme-transition-reverse::view-transition-old(root) {
z-index: 9999;
}
.theme-transition-reverse::view-transition-new(root) {
z-index: 1;
}
:root { :root {
/* 亮色主题 */ /* 亮色主题 */
--color-bg-primary: #ffffff; --color-bg-primary: #ffffff;
@@ -168,6 +168,7 @@ onMounted(() => {
}) })
window.addEventListener('mousemove', onMouseMove, { passive: true }) window.addEventListener('mousemove', onMouseMove, { passive: true })
document.addEventListener('visibilitychange', onVisibilityChange) document.addEventListener('visibilitychange', onVisibilityChange)
draw() // Initial draw to ensure no flicker
raf = requestAnimationFrame(loop) raf = requestAnimationFrame(loop)
}) })
@@ -185,6 +185,7 @@ onMounted(() => {
initMesh() initMesh()
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
window.addEventListener('mousemove', onMouseMove) window.addEventListener('mousemove', onMouseMove)
draw() // Initial draw
raf = requestAnimationFrame(loop) raf = requestAnimationFrame(loop)
}) })
@@ -132,6 +132,7 @@ onMounted(() => {
resize() resize()
window.addEventListener('resize', resize) window.addEventListener('resize', resize)
window.addEventListener('mousemove', onMouseMove) window.addEventListener('mousemove', onMouseMove)
draw() // Initial draw
raf = requestAnimationFrame(loop) raf = requestAnimationFrame(loop)
}) })
+5 -15
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue';
import { usePromptStore } from '../stores/promptStore'; import { usePromptStore } from '../stores/promptStore';
import type { PromptPreset, PresetType } from '../types'; import type { PromptPreset, PresetType } from '../types';
import NotificationToast from './NotificationToast.vue'; import NotificationToast from './NotificationToast.vue';
import IconPresetType from './icons/IconPresetType.vue';
const store = usePromptStore(); const store = usePromptStore();
@@ -325,19 +326,6 @@ function importPreset(event: Event) {
(event.target as HTMLInputElement).value = ''; (event.target as HTMLInputElement).value = '';
} }
function getTypeIcon(type: string) {
const icons: Record<string, string> = {
positive: '🪄',
negative: '⛔',
setting: '⚙️',
style: '🖌️',
character: '🧙',
scene: '🏞️',
custom: '🧩'
};
return icons[type] || '🧩';
}
function getTypeLabel(type: string) { function getTypeLabel(type: string) {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
'positive': '正面', 'positive': '正面',
@@ -513,12 +501,14 @@ onUnmounted(() => {
<span class="pd-group-count">{{ group.presets.length }}</span> <span class="pd-group-count">{{ group.presets.length }}</span>
</div> </div>
<div v-for="p in group.presets" :key="`${p.name}_${p.type}`" class="pd-item"> <div v-for="p in group.presets" :key="`${p.name}_${p.type}`" class="pd-item nav-btn">
<template v-if="renamingPreset !== p.name"> <template v-if="renamingPreset !== p.name">
<div class="pd-item-main" @click="loadPreset(p.name)"> <div class="pd-item-main" @click="loadPreset(p.name)">
<div class="pd-item-header"> <div class="pd-item-header">
<div class="pd-item-title"> <div class="pd-item-title">
<span class="pd-item-icon" :title="getTypeLabel(p.type)">{{ getTypeIcon(p.type) }}</span> <span class="pd-item-icon" :title="getTypeLabel(p.type)">
<IconPresetType :type="p.type" width="16" height="16" />
</span>
<span class="pd-item-name">{{ p.name }}</span> <span class="pd-item-name">{{ p.name }}</span>
</div> </div>
<span class="pd-item-date">{{ formatDate(p.updatedAt) }}</span> <span class="pd-item-date">{{ formatDate(p.updatedAt) }}</span>
+23 -19
View File
@@ -6,6 +6,7 @@ import NotificationToast from './NotificationToast.vue';
import PresetSidebar from './preset/PresetSidebar.vue'; import PresetSidebar from './preset/PresetSidebar.vue';
import PresetList from './preset/PresetList.vue'; import PresetList from './preset/PresetList.vue';
import FolderSelector from './preset/FolderSelector.vue'; import FolderSelector from './preset/FolderSelector.vue';
import TypeSelector from './preset/TypeSelector.vue';
const store = usePromptStore(); const store = usePromptStore();
@@ -55,14 +56,14 @@ function showNotification(message: string, type: 'success' | 'error' | 'info' =
} }
// Preset Types // Preset Types
const presetTypes: { value: PresetType; label: string; icon: string }[] = [ const presetTypes: { value: PresetType; label: string }[] = [
{ value: 'positive', label: '正面提示词', icon: '🪄' }, { value: 'positive', label: '正面提示词' },
{ value: 'negative', label: '负面提示词', icon: '⛔' }, { value: 'negative', label: '负面提示词' },
{ value: 'setting', label: '设定标签', icon: '⚙️' }, { value: 'setting', label: '设定标签' },
{ value: 'style', label: '风格样式', icon: '🖌️' }, { value: 'style', label: '风格样式' },
{ value: 'character', label: '角色人物', icon: '🧙' }, { value: 'character', label: '角色人物' },
{ value: 'scene', label: '场景环境', icon: '🏞️' }, { value: 'scene', label: '场景环境' },
{ value: 'custom', label: '自定义', icon: '🧩' } { value: 'custom', label: '自定义' }
]; ];
// Computed // Computed
@@ -81,6 +82,11 @@ const folderTree = computed(() => {
return buildTree(rootFolders); return buildTree(rootFolders);
}); });
const filterOptions = computed<{ value: PresetType | 'all'; label: string }[]>(() => [
{ value: 'all', label: '所有类型' },
...presetTypes
]);
const filteredPresets = computed(() => { const filteredPresets = computed(() => {
let presets = store.extendedPresets || []; let presets = store.extendedPresets || [];
@@ -723,12 +729,10 @@ onMounted(() => {
</div> </div>
<div class="filter-group"> <div class="filter-group">
<select v-model="selectedType" class="type-select"> <TypeSelector
<option value="all">所有类型</option> v-model="selectedType"
<option v-for="type in presetTypes" :key="type.value" :value="type.value"> :options="filterOptions"
{{ type.icon }} {{ type.label }} />
</option>
</select>
</div> </div>
<div class="action-group"> <div class="action-group">
@@ -795,11 +799,11 @@ onMounted(() => {
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>类型</label> <label>类型</label>
<select v-model="presetForm.type"> <TypeSelector
<option v-for="t in presetTypes" :key="t.value" :value="t.value"> :modelValue="presetForm.type"
{{ t.icon }} {{ t.label }} @update:modelValue="val => presetForm.type = val as PresetType"
</option> :options="presetTypes"
</select> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>文件夹</label> <label>文件夹</label>
+40 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { usePromptStore } from '../stores/promptStore'; import { usePromptStore } from '../stores/promptStore';
import type { PromptTag } from '../types'; import type { PromptTag } from '../types';
@@ -14,6 +14,32 @@ const currentGroup = computed(() => store.currentGroup);
const filteredTags = computed(() => store.filteredTags); const filteredTags = computed(() => store.filteredTags);
const selectedLang = computed(() => store.selectedLang); const selectedLang = computed(() => store.selectedLang);
const PAGE_SIZE = 50;
const visibleCount = ref(PAGE_SIZE);
const tagsContainer = ref<HTMLElement | null>(null);
const visibleTags = computed(() => {
return filteredTags.value.slice(0, visibleCount.value);
});
watch(() => filteredTags.value, () => {
visibleCount.value = PAGE_SIZE;
if (tagsContainer.value) {
tagsContainer.value.scrollTop = 0;
}
});
function onScroll() {
const el = tagsContainer.value;
if (!el) return;
// Simple infinite scroll trigger
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
if (visibleCount.value < filteredTags.value.length) {
visibleCount.value += PAGE_SIZE;
}
}
}
function selectCategory(index: number) { function selectCategory(index: number) {
store.selectCategory(index); store.selectCategory(index);
} }
@@ -56,8 +82,8 @@ function displayTrans(tag: PromptTag) {
</div> </div>
<!-- Tags --> <!-- Tags -->
<div class="pqa-tags"> <div class="pqa-tags" ref="tagsContainer" @scroll="onScroll">
<button v-for="tag in filteredTags" :key="tag.key" class="pqa-tag" @click="onTagClick(tag)" @mousedown.prevent <button v-for="tag in visibleTags" :key="tag.key" class="pqa-tag" @click="onTagClick(tag)" @mousedown.prevent
:title="tag.key"> :title="tag.key">
<span class="pqa-tag-text">{{ displayTrans(tag) }}</span> <span class="pqa-tag-text">{{ displayTrans(tag) }}</span>
<span class="pqa-tag-sub" v-if="displayTrans(tag) !== tag.key">{{ tag.key }}</span> <span class="pqa-tag-sub" v-if="displayTrans(tag) !== tag.key">{{ tag.key }}</span>
@@ -65,6 +91,9 @@ function displayTrans(tag: PromptTag) {
<div v-if="filteredTags.length === 0" class="pqa-empty"> <div v-if="filteredTags.length === 0" class="pqa-empty">
无相关提示词 无相关提示词
</div> </div>
<div v-if="visibleCount < filteredTags.length" class="pqa-loading-more">
...
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -235,4 +264,12 @@ function displayTrans(tag: PromptTag) {
font-size: 0.8rem; font-size: 0.8rem;
padding: 2rem; padding: 2rem;
} }
.pqa-loading-more {
width: 100%;
text-align: center;
color: var(--color-text-tertiary);
padding: 0.5rem;
font-size: 0.8rem;
}
</style> </style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
mode: 'particles' | 'grid' | 'gradient' | 'off'
}>()
const iconName = computed(() => {
switch (props.mode) {
case 'particles': return 'icon-particles'
case 'grid': return 'icon-grid'
case 'gradient': return 'icon-gradient'
default: return 'icon-off'
}
})
</script>
<template>
<div class="icon-bg-container">
<Transition name="icon-fade" mode="out-in">
<svg v-if="mode === 'particles'" key="particles" width="24" height="24" viewBox="0 0 24 24" fill="none" class="icon-particles">
<circle cx="6" cy="12" r="1.5" fill="currentColor" class="dot-1"/>
<circle cx="12" cy="9" r="1.5" fill="currentColor" class="dot-2"/>
<circle cx="18" cy="13" r="1.5" fill="currentColor" class="dot-3"/>
<path d="M4 16c4-2 8-2 12 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" class="wave"/>
</svg>
<svg v-else-if="mode === 'grid'" key="grid" width="24" height="24" viewBox="0 0 24 24" fill="none" class="icon-grid">
<path d="M3 3h18v18H3z" stroke="currentColor" stroke-width="2" class="grid-border"/>
<path d="M3 9h18M3 15h18M9 3v18M15 3v18" stroke="currentColor" stroke-width="2" class="grid-lines"/>
</svg>
<svg v-else-if="mode === 'gradient'" key="gradient" width="24" height="24" viewBox="0 0 24 24" fill="none" class="icon-gradient">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" class="gradient-circle"/>
<path d="M8 12s1.5-2 4-2 4 2 4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="gradient-wave"/>
</svg>
<svg v-else key="off" width="24" height="24" viewBox="0 0 24 24" fill="none" class="icon-off">
<path d="M5 5l14 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="6" stroke="currentColor" stroke-width="2"/>
</svg>
</Transition>
</div>
</template>
<style scoped>
.icon-bg-container {
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.icon-fade-enter-active,
.icon-fade-leave-active {
transition: all 0.2s ease;
}
.icon-fade-enter-from {
opacity: 0;
transform: scale(0.8) rotate(-90deg);
}
.icon-fade-leave-to {
opacity: 0;
transform: scale(0.8) rotate(90deg);
}
/* Animations */
.dot-1, .dot-2, .dot-3 {
animation: float 3s ease-in-out infinite;
}
.dot-2 { animation-delay: 0.5s; }
.dot-3 { animation-delay: 1s; }
.grid-lines {
transition: opacity 0.3s;
}
.icon-grid:hover .grid-lines {
opacity: 0.5;
}
.gradient-circle {
transition: stroke-dasharray 0.5s;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
.icon-bg-container:hover .wave {
animation: wave 2s linear infinite;
}
@keyframes wave {
0% { d: path("M4 16c4-2 8-2 12 0"); }
50% { d: path("M4 16c4 2 8 2 12 0"); }
100% { d: path("M4 16c4-2 8-2 12 0"); }
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon-editor"
>
<path class="paper" d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path class="pencil" d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</template>
<style scoped>
.icon-editor {
overflow: visible;
}
.pencil {
transform-origin: center;
transition: transform 0.3s ease;
}
.paper {
transition: stroke-dashoffset 0.3s ease;
}
/* Hover effect handled by parent class usually, or we can use :hover if the svg itself is the target */
:deep(.nav-btn:hover) .pencil,
svg:hover .pencil {
transform: translate(2px, -2px) rotate(5deg);
animation: write 1s ease-in-out infinite alternate;
}
@keyframes write {
0% { transform: translate(0, 0) rotate(0); }
100% { transform: translate(2px, -2px) rotate(10deg); }
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<template>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
class="icon-github"
>
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.49.5.09.682-.217.682-.483 0-.237-.009-.868-.013-1.705-2.782.604-3.37-1.342-3.37-1.342-.455-1.157-1.111-1.466-1.111-1.466-.908-.62.069-.607.069-.607 1.003.07 1.53 1.03 1.53 1.03.892 1.528 2.341 1.087 2.91.832.092-.646.35-1.087.637-1.338-2.221-.253-4.558-1.11-4.558-4.941 0-1.091.39-1.984 1.029-2.682-.104-.254-.446-1.274.098-2.656 0 0 .84-.269 2.753 1.025.798-.222 1.653-.333 2.504-.337.85.004 1.706.115 2.504.337 1.911-1.294 2.75-1.025 2.75-1.025.546 1.382.203 2.402.1 2.656.64.698 1.028 1.591 1.028 2.682 0 3.84-2.34 4.685-4.566 4.934.359.309.679.919.679 1.853 0 1.337-.012 2.415-.012 2.744 0 .268.18.577.688.479C19.137 20.163 22 16.416 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
</template>
<style scoped>
.icon-github {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.icon-github:hover {
transform: scale(1.1) rotate(5deg);
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon-manager"
>
<path class="disk-top" d="M21 5c0 1.66-4 3-9 3s-9-1.34-9-3 4-3 9-3 9 1.34 9 3z" />
<path class="disk-middle" d="M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5" />
<path class="disk-bottom" d="M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6" />
</svg>
</template>
<style scoped>
.disk-top, .disk-middle, .disk-bottom {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
:deep(.nav-btn:hover) .disk-top,
svg:hover .disk-top {
transform: translateY(-1px);
}
:deep(.nav-btn:hover) .disk-bottom,
svg:hover .disk-bottom {
transform: translateY(1px);
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon-preset-type"
:class="type"
>
<!-- Positive: Sparkles/Magic -->
<template v-if="type === 'positive'">
<path class="star-main" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</template>
<!-- Negative: Ban/Stop -->
<template v-else-if="type === 'negative'">
<circle class="circle" cx="12" cy="12" r="10" />
<line class="slash" x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</template>
<!-- Setting: Gear -->
<template v-else-if="type === 'setting'">
<path class="gear" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L3.16 8.87c-.09.17-.05.39.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.58 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.09-.17.05-.39-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
</template>
<!-- Style: Brush -->
<template v-else-if="type === 'style'">
<path class="brush-handle" d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3z"/>
<path class="brush-tip" d="M20.71 4.63l-1.34-1.34a.996.996 0 0 0-1.41 0L9 12.25 11.75 15l8.96-8.96a.996.996 0 0 0 0-1.41z"/>
</template>
<!-- Character: User/Face -->
<template v-else-if="type === 'character'">
<path class="user-body" d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle class="user-head" cx="12" cy="7" r="4" />
</template>
<!-- Scene: Image/Landscape -->
<template v-else-if="type === 'scene'">
<rect class="scene-frame" x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle class="scene-sun" cx="8.5" cy="8.5" r="1.5"/>
<polyline class="scene-mountain" points="21 15 16 10 5 21"/>
</template>
<!-- Custom: Puzzle -->
<template v-else>
<path class="puzzle-piece" d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5A2.5 2.5 0 0 0 10.5 1 2.5 2.5 0 0 0 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5a2.5 2.5 0 0 0 2.5-2.5 2.5 2.5 0 0 0-2.5-2.5z"/>
</template>
</svg>
</template>
<script setup lang="ts">
import type { PresetType } from '../../types';
defineProps<{
type: string | PresetType
}>();
</script>
<style scoped>
.icon-preset-type {
transition: all 0.3s ease;
overflow: visible;
}
/* Positive: Sparkle/Twinkle */
.positive .star-main {
transform-origin: center;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
:deep(.nav-btn:hover) .positive .star-main,
.positive:hover .star-main {
transform: scale(1.1) rotate(15deg);
fill: currentColor;
fill-opacity: 0.2;
}
/* Negative: Shake */
.negative .slash {
transform-origin: center;
transition: transform 0.3s ease;
}
:deep(.nav-btn:hover) .negative,
.negative:hover {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
/* Setting: Rotate */
.setting .gear {
transform-origin: center;
transition: transform 0.5s ease;
}
:deep(.nav-btn:hover) .setting .gear,
.setting:hover .gear {
transform: rotate(90deg);
}
/* Style: Brush Wiggle */
.style .brush-tip {
transform-origin: bottom left;
transition: transform 0.3s ease;
}
:deep(.nav-btn:hover) .style .brush-tip,
.style:hover .brush-tip {
transform: rotate(-10deg) translateY(-2px);
}
/* Character: Bounce */
.character .user-head {
transform-origin: center;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
:deep(.nav-btn:hover) .character .user-head,
.character:hover .user-head {
transform: translateY(-2px);
}
/* Scene: Zoom */
.scene .scene-mountain {
transform-origin: bottom;
transition: transform 0.3s ease;
}
.scene .scene-sun {
transform-origin: center;
transition: transform 0.3s ease;
}
:deep(.nav-btn:hover) .scene .scene-mountain,
.scene:hover .scene-mountain {
transform: scaleY(1.1);
}
:deep(.nav-btn:hover) .scene .scene-sun,
.scene:hover .scene-sun {
transform: translateY(-2px) scale(1.1);
}
/* Custom: Puzzle Fit */
.custom .puzzle-piece {
transform-origin: center;
transition: transform 0.3s ease;
}
:deep(.nav-btn:hover) .custom .puzzle-piece,
.custom:hover .puzzle-piece {
transform: scale(1.1) rotate(-5deg);
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon-presets"
>
<path class="layout-main" d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline class="layout-line-1" points="17 21 17 13 7 13 7 21" />
<polyline class="layout-line-2" points="7 3 7 8 15 8" />
</svg>
</template>
<style scoped>
.layout-main, .layout-line-1, .layout-line-2 {
transition: all 0.3s ease;
transform-origin: center;
}
:deep(.nav-btn:hover) .layout-line-1,
svg:hover .layout-line-1 {
transform: scaleY(0.9) translateY(1px);
}
:deep(.nav-btn:hover) .layout-line-2,
svg:hover .layout-line-2 {
transform: translateX(1px);
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<script setup lang="ts">
defineProps<{
isDark: boolean
}>()
</script>
<template>
<div class="icon-theme-container">
<div class="moon-glow" :class="{ 'is-active': isDark }"></div>
<svg
class="icon-sun"
:class="{ 'is-hidden': isDark }"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg
class="icon-moon"
:class="{ 'is-visible': isDark }"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
<g class="stars" stroke="none" stroke-width="0" fill="currentColor">
<path class="star star-1" d="M17 8 L17.5 9.5 L19 10 L17.5 10.5 L17 12 L16.5 10.5 L15 10 L16.5 9.5 Z" />
<path class="star star-2" d="M13 3.5 L13.3 4.5 L14.3 4.8 L13.3 5.1 L13 6.1 L12.7 5.1 L11.7 4.8 L12.7 4.5 Z" />
</g>
</svg>
</div>
</template>
<style scoped>
.icon-theme-container {
position: relative;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.moon-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.5);
width: 200%;
height: 200%;
border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%);
opacity: 0;
transition: all 0.4s ease;
z-index: 0;
pointer-events: none;
}
.icon-theme-container:hover .moon-glow.is-active {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
svg {
position: absolute;
width: 100%;
height: 100%;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.icon-sun {
opacity: 1;
transform: rotate(0) scale(1);
}
.icon-sun.is-hidden {
opacity: 0;
transform: rotate(90deg) scale(0.5);
}
.icon-moon {
opacity: 0;
transform: rotate(-90deg) scale(0.5);
}
.icon-moon.is-visible {
opacity: 1;
transform: rotate(0) scale(1);
}
/* Star styles */
.star {
transform-origin: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.icon-moon.is-visible .star {
opacity: 1;
}
/* Hover effects */
.icon-theme-container:hover .icon-sun:not(.is-hidden) {
animation: spin 4s linear infinite;
}
.icon-theme-container:hover .icon-moon.is-visible {
animation: swing 2.5s ease-in-out infinite;
}
.icon-theme-container:hover .star-1 {
animation: twinkle 1.5s infinite ease-in-out;
}
.icon-theme-container:hover .star-2 {
animation: twinkle 2s infinite ease-in-out 0.2s;
}
@keyframes spin {
from { transform: rotate(0); }
to { transform: rotate(360deg); }
}
@keyframes swing {
0%, 100% { transform: rotate(0) scale(1); }
50% { transform: rotate(-10deg) scale(1.05); }
}
@keyframes twinkle {
0%, 100% { opacity: 0.5; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
</style>
+83 -18
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue';
import type { ExtendedPreset, PresetType } from '../../types'; import type { ExtendedPreset, PresetType } from '../../types';
import IconPresetType from '../icons/IconPresetType.vue';
const props = defineProps<{ const props = defineProps<{
presets: ExtendedPreset[]; presets: ExtendedPreset[];
@@ -16,17 +17,73 @@ const emit = defineEmits<{
(e: 'toggle-favorite', preset: ExtendedPreset): void; (e: 'toggle-favorite', preset: ExtendedPreset): void;
}>(); }>();
function getTypeIcon(type: PresetType) { // Lazy Loading Logic
const icons: Record<string, string> = { const PAGE_SIZE = 20;
positive: '🪄', const displayLimit = ref(PAGE_SIZE);
negative: '⛔', const containerRef = ref<HTMLElement | null>(null);
setting: '⚙️', const sentinelRef = ref<HTMLElement | null>(null);
style: '🖌️', let observer: IntersectionObserver | null = null;
character: '🧙',
scene: '🏞️', const displayedPresets = computed(() => {
custom: '🧩' return props.presets.slice(0, displayLimit.value);
}; });
return icons[type] || '🧩';
watch(() => props.presets, () => {
displayLimit.value = PAGE_SIZE;
if (containerRef.value) {
containerRef.value.scrollTop = 0;
}
// Reset observer if needed, but the sentinel remains or is recreated
nextTick(() => {
checkIntersection();
});
});
function checkIntersection() {
if (observer && sentinelRef.value) {
// Re-observe just in case
observer.disconnect();
observer.observe(sentinelRef.value);
}
}
onMounted(() => {
observer = new IntersectionObserver((entries) => {
if (entries[0] && entries[0].isIntersecting) {
loadMore();
}
}, {
root: containerRef.value,
rootMargin: '200px',
threshold: 0.1
});
if (sentinelRef.value) {
observer.observe(sentinelRef.value);
}
});
onUnmounted(() => {
if (observer) {
observer.disconnect();
observer = null;
}
});
// Watch sentinel ref changes (e.g. when switching from empty to having presets)
watch(sentinelRef, (newEl) => {
if (observer) {
observer.disconnect();
if (newEl) {
observer.observe(newEl);
}
}
});
function loadMore() {
if (displayLimit.value < props.presets.length) {
displayLimit.value += PAGE_SIZE;
}
} }
function getTypeLabel(type: PresetType) { function getTypeLabel(type: PresetType) {
@@ -48,17 +105,18 @@ function formatDate(dateStr: string) {
</script> </script>
<template> <template>
<div class="preset-list-container"> <div class="preset-list-container" ref="containerRef">
<div v-if="presets.length === 0" class="empty-state"> <div v-if="presets.length === 0" class="empty-state">
<div class="empty-icon">📭</div> <div class="empty-icon">📭</div>
<p class="empty-text">暂无预设</p> <p class="empty-text">暂无预设</p>
</div> </div>
<div v-else class="preset-grid"> <template v-else>
<div v-for="preset in presets" :key="preset.id" class="preset-card"> <div class="preset-grid">
<div v-for="preset in displayedPresets" :key="preset.id" class="preset-card nav-btn">
<div class="card-header"> <div class="card-header">
<div class="preset-type" :title="getTypeLabel(preset.type)"> <div class="preset-type" :title="getTypeLabel(preset.type)">
{{ getTypeIcon(preset.type) }} <IconPresetType :type="preset.type" width="24" height="24" />
</div> </div>
<h4 class="preset-name" :title="preset.name">{{ preset.name }}</h4> <h4 class="preset-name" :title="preset.name">{{ preset.name }}</h4>
<div class="preset-actions"> <div class="preset-actions">
@@ -132,6 +190,8 @@ function formatDate(dateStr: string) {
</div> </div>
</div> </div>
</div> </div>
<div ref="sentinelRef" class="sentinel" style="height: 20px; width: 100%;"></div>
</template>
</div> </div>
</template> </template>
@@ -168,11 +228,13 @@ function formatDate(dateStr: string) {
background-color: var(--color-bg-primary); background-color: var(--color-bg-primary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1rem; padding: 0.75rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch;
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative; position: relative;
text-align: left;
} }
.preset-card:hover { .preset-card:hover {
@@ -189,8 +251,11 @@ function formatDate(dateStr: string) {
.preset-type { .preset-type {
font-size: 1.25rem; font-size: 1.25rem;
margin-right: 0.75rem; margin-right: 0.5rem;
flex-shrink: 0; flex-shrink: 0;
line-height: 1;
display: flex;
align-items: center;
} }
.preset-name { .preset-name {
+207
View File
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import type { PresetType } from '../../types';
import IconPresetType from '../icons/IconPresetType.vue';
const props = defineProps<{
modelValue: PresetType | 'all';
options: { value: PresetType | 'all'; label: string }[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: PresetType | 'all'): void;
}>();
const isOpen = ref(false);
const containerRef = ref<HTMLElement | null>(null);
const selectedLabel = computed(() => {
const option = props.options.find(o => o.value === props.modelValue);
return option ? option.label : '所有类型';
});
function toggle() {
isOpen.value = !isOpen.value;
}
function select(value: PresetType | 'all') {
emit('update:modelValue', value);
isOpen.value = false;
}
function handleClickOutside(event: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
isOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<template>
<div class="type-selector" ref="containerRef">
<button class="selector-btn nav-btn" @click="toggle" :class="{ active: isOpen }">
<div class="selected-content">
<IconPresetType v-if="modelValue !== 'all'" :type="modelValue" width="16" height="16" />
<span v-else class="all-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</span>
<span>{{ selectedLabel }}</span>
</div>
<svg class="chevron" :class="{ rotated: isOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<Transition name="fade">
<div v-if="isOpen" class="options-list">
<button
v-for="option in options"
:key="option.value"
class="option-item nav-btn"
:class="{ selected: modelValue === option.value }"
@click="select(option.value)"
>
<div class="option-icon">
<IconPresetType v-if="option.value !== 'all'" :type="option.value" width="16" height="16" />
<span v-else class="all-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</span>
</div>
<span>{{ option.label }}</span>
<svg v-if="modelValue === option.value" class="check-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.type-selector {
position: relative;
min-width: 140px;
}
.selector-btn {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
cursor: pointer;
transition: all 0.2s;
}
.selector-btn:hover, .selector-btn.active {
border-color: var(--color-border-hover);
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.selected-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chevron {
transition: transform 0.2s;
color: var(--color-text-tertiary);
}
.chevron.rotated {
transform: rotate(180deg);
}
.options-list {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
min-width: 160px;
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 50;
max-height: 300px;
overflow-y: auto;
padding: 0.25rem;
}
.option-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem;
border: none;
background: none;
color: var(--color-text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background-color 0.2s;
text-align: left;
}
.option-item:hover {
background-color: var(--color-bg-secondary);
}
.option-item.selected {
background-color: var(--color-bg-tertiary);
font-weight: 500;
color: var(--color-text-primary);
}
.option-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 0.5rem;
color: var(--color-text-secondary);
}
.all-icon {
display: flex;
align-items: center;
justify-content: center;
}
.check-icon {
margin-left: auto;
color: var(--color-accent);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-5px);
}
</style>
+1 -1
View File
@@ -39,7 +39,7 @@ body {
background-color: #ffffff; background-color: #ffffff;
} }
a:hover { a:hover {
color: #747bff; color: #2da44e;
} }
button { button {
background-color: #f9f9f9; background-color: #f9f9f9;