修复预设过多切换主题卡顿问题,更改预设图标样式
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 || [];
|
||||||
|
|
||||||
@@ -677,12 +683,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">
|
||||||
@@ -748,11 +752,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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[];
|
||||||
@@ -15,17 +16,73 @@ const emit = defineEmits<{
|
|||||||
(e: 'share', preset: ExtendedPreset): void;
|
(e: 'share', 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) {
|
||||||
@@ -47,17 +104,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">
|
||||||
@@ -126,6 +184,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>
|
||||||
|
|
||||||
@@ -162,11 +222,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 {
|
||||||
@@ -183,8 +245,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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user