Compare commits
10 Commits
706940bf93
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 31d88755b1 | |||
| 58260797b2 | |||
| c3739b18eb | |||
| 875eb21241 | |||
| 63dbc5cfff | |||
| d9adab89ac | |||
| 021be8f22b | |||
| 01ca52cfc7 | |||
| 5c1b6a86ca | |||
| e9a29d6006 |
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||||
import { usePromptStore } from '../stores/promptStore';
|
import { usePromptStore } from '../stores/promptStore';
|
||||||
import type { ExtendedPreset, PresetFolder, PresetType } from '../types';
|
import type { ExtendedPreset, PresetFolder, PresetType } from '../types';
|
||||||
import NotificationToast from './NotificationToast.vue';
|
import NotificationToast from './NotificationToast.vue';
|
||||||
@@ -7,8 +7,16 @@ 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';
|
import TypeSelector from './preset/TypeSelector.vue';
|
||||||
|
import IconMenu from './icons/IconMenu.vue';
|
||||||
|
import IconSearch from './icons/IconSearch.vue';
|
||||||
|
import IconSort from './icons/IconSort.vue';
|
||||||
|
import IconPlus from './icons/IconPlus.vue';
|
||||||
|
import IconShare from './icons/IconShare.vue';
|
||||||
|
import IconExport from './icons/IconExport.vue';
|
||||||
|
import IconImport from './icons/IconImport.vue';
|
||||||
|
|
||||||
const store = usePromptStore();
|
const store = usePromptStore();
|
||||||
|
const PRESET_MANAGER_VIEW_STATE_KEY = 'preset-manager-view-state';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const activeTab = ref<'presets' | 'folders'>('presets'); // Kept for compatibility if needed, but UI will be unified
|
const activeTab = ref<'presets' | 'folders'>('presets'); // Kept for compatibility if needed, but UI will be unified
|
||||||
@@ -16,6 +24,11 @@ const selectedType = ref<PresetType | 'all'>('all');
|
|||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedFolderId = ref<string | null>(null);
|
const selectedFolderId = ref<string | null>(null);
|
||||||
const expandedFolderIds = ref<Set<string>>(new Set());
|
const expandedFolderIds = ref<Set<string>>(new Set());
|
||||||
|
const sortField = ref<'custom' | 'name' | 'updatedAt'>('custom');
|
||||||
|
const sortDirection = ref<'asc' | 'desc'>('asc');
|
||||||
|
const presetSidebarRef = ref<InstanceType<typeof PresetSidebar> | null>(null);
|
||||||
|
const presetListRef = ref<InstanceType<typeof PresetList> | null>(null);
|
||||||
|
const isRestoringViewState = ref(false);
|
||||||
|
|
||||||
// Dialog State
|
// Dialog State
|
||||||
const showMobileSidebar = ref(false);
|
const showMobileSidebar = ref(false);
|
||||||
@@ -87,8 +100,26 @@ const filterOptions = computed<{ value: PresetType | 'all'; label: string }[]>((
|
|||||||
...presetTypes
|
...presetTypes
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: 'custom', label: '自定义排序' },
|
||||||
|
{ value: 'name', label: '按名称' },
|
||||||
|
{ value: 'updatedAt', label: '按时间' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const isCustomSort = computed(() => sortField.value === 'custom');
|
||||||
|
|
||||||
|
const sortDirectionLabel = computed(() => {
|
||||||
|
if (sortField.value === 'name') {
|
||||||
|
return sortDirection.value === 'asc' ? '名称 A-Z' : '名称 Z-A';
|
||||||
|
}
|
||||||
|
if (sortField.value === 'updatedAt') {
|
||||||
|
return sortDirection.value === 'asc' ? '时间旧到新' : '时间新到旧';
|
||||||
|
}
|
||||||
|
return sortDirection.value === 'asc' ? '正序' : '倒序';
|
||||||
|
});
|
||||||
|
|
||||||
const filteredPresets = computed(() => {
|
const filteredPresets = computed(() => {
|
||||||
let presets = store.extendedPresets || [];
|
let presets = [...(store.extendedPresets || [])];
|
||||||
|
|
||||||
// Filter by Type
|
// Filter by Type
|
||||||
if (selectedType.value !== 'all') {
|
if (selectedType.value !== 'all') {
|
||||||
@@ -117,9 +148,45 @@ const filteredPresets = computed(() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return presets.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
const sorted = [...presets];
|
||||||
|
if (sortField.value === 'custom') {
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const ao = typeof a.sortOrder === 'number' ? a.sortOrder : Number.POSITIVE_INFINITY;
|
||||||
|
const bo = typeof b.sortOrder === 'number' ? b.sortOrder : Number.POSITIVE_INFINITY;
|
||||||
|
if (ao !== bo) return sortDirection.value === 'asc' ? ao - bo : bo - ao;
|
||||||
|
const timeDiff = new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
return sortDirection.value === 'asc' ? timeDiff : -timeDiff;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
if (sortField.value === 'name') {
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const compare = a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
|
||||||
|
if (compare !== 0) {
|
||||||
|
return sortDirection.value === 'asc' ? compare : -compare;
|
||||||
|
}
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const compare = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||||
|
if (compare !== 0) {
|
||||||
|
return sortDirection.value === 'asc' ? compare : -compare;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const presetListResetKey = computed(() => JSON.stringify({
|
||||||
|
selectedType: selectedType.value,
|
||||||
|
searchQuery: searchQuery.value,
|
||||||
|
selectedFolderId: selectedFolderId.value,
|
||||||
|
sortField: sortField.value,
|
||||||
|
sortDirection: sortDirection.value,
|
||||||
|
}));
|
||||||
|
|
||||||
const flattenedFolders = computed(() => {
|
const flattenedFolders = computed(() => {
|
||||||
type FlatItem = { id: string; name: string; label: string; level: number; presetCount: number; hasChildren: boolean };
|
type FlatItem = { id: string; name: string; label: string; level: number; presetCount: number; hasChildren: boolean };
|
||||||
const res: FlatItem[] = [];
|
const res: FlatItem[] = [];
|
||||||
@@ -147,6 +214,68 @@ const allPresetsCount = computed(() => (store.extendedPresets || []).length);
|
|||||||
const uncategorizedCount = computed(() => (store.extendedPresets || []).filter(p => !p.folderId).length);
|
const uncategorizedCount = computed(() => (store.extendedPresets || []).filter(p => !p.folderId).length);
|
||||||
const favoritesCount = computed(() => (store.extendedPresets || []).filter(p => p.isFavorite).length);
|
const favoritesCount = computed(() => (store.extendedPresets || []).filter(p => p.isFavorite).length);
|
||||||
|
|
||||||
|
function persistPresetManagerViewState() {
|
||||||
|
if (isRestoringViewState.value) return;
|
||||||
|
const payload = {
|
||||||
|
activeTab: activeTab.value,
|
||||||
|
selectedType: selectedType.value,
|
||||||
|
searchQuery: searchQuery.value,
|
||||||
|
selectedFolderId: selectedFolderId.value,
|
||||||
|
expandedFolderIds: Array.from(expandedFolderIds.value),
|
||||||
|
sortField: sortField.value,
|
||||||
|
sortDirection: sortDirection.value,
|
||||||
|
sidebarScrollTop: presetSidebarRef.value?.contentRef?.scrollTop ?? 0,
|
||||||
|
listScrollTop: presetListRef.value?.containerRef?.scrollTop ?? 0,
|
||||||
|
currentPage: presetListRef.value?.getCurrentPage?.() ?? 1,
|
||||||
|
};
|
||||||
|
window.sessionStorage.setItem(PRESET_MANAGER_VIEW_STATE_KEY, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restorePresetManagerViewState() {
|
||||||
|
const raw = window.sessionStorage.getItem(PRESET_MANAGER_VIEW_STATE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
isRestoringViewState.value = true;
|
||||||
|
const state = JSON.parse(raw) as {
|
||||||
|
activeTab?: 'presets' | 'folders';
|
||||||
|
selectedType?: PresetType | 'all';
|
||||||
|
searchQuery?: string;
|
||||||
|
selectedFolderId?: string | null;
|
||||||
|
expandedFolderIds?: string[];
|
||||||
|
sortField?: 'custom' | 'name' | 'updatedAt';
|
||||||
|
sortDirection?: 'asc' | 'desc';
|
||||||
|
sidebarScrollTop?: number;
|
||||||
|
listScrollTop?: number;
|
||||||
|
currentPage?: number;
|
||||||
|
};
|
||||||
|
if (state.activeTab === 'presets' || state.activeTab === 'folders') {
|
||||||
|
activeTab.value = state.activeTab;
|
||||||
|
}
|
||||||
|
selectedType.value = state.selectedType ?? 'all';
|
||||||
|
searchQuery.value = state.searchQuery ?? '';
|
||||||
|
selectedFolderId.value = state.selectedFolderId ?? null;
|
||||||
|
expandedFolderIds.value = new Set(state.expandedFolderIds ?? []);
|
||||||
|
sortField.value = state.sortField ?? 'custom';
|
||||||
|
sortDirection.value = state.sortDirection ?? 'asc';
|
||||||
|
await nextTick();
|
||||||
|
if (typeof state.currentPage === 'number') {
|
||||||
|
presetListRef.value?.setCurrentPage?.(state.currentPage, false);
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
if (presetSidebarRef.value?.contentRef) {
|
||||||
|
presetSidebarRef.value.contentRef.scrollTop = Math.max(0, state.sidebarScrollTop ?? 0);
|
||||||
|
}
|
||||||
|
if (presetListRef.value?.containerRef) {
|
||||||
|
presetListRef.value.containerRef.scrollTop = Math.max(0, state.listScrollTop ?? 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore broken persisted state and continue with defaults.
|
||||||
|
} finally {
|
||||||
|
isRestoringViewState.value = false;
|
||||||
|
persistPresetManagerViewState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
function handleFolderSelect(id: string | null) {
|
function handleFolderSelect(id: string | null) {
|
||||||
selectedFolderId.value = id;
|
selectedFolderId.value = id;
|
||||||
@@ -158,6 +287,14 @@ function handleToggleExpand(id: string) {
|
|||||||
expandedFolderIds.value = set;
|
expandedFolderIds.value = set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSortField(field: 'custom' | 'name' | 'updatedAt') {
|
||||||
|
sortField.value = field;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSortDirection() {
|
||||||
|
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
function createFolder(parentId?: string) {
|
function createFolder(parentId?: string) {
|
||||||
resetFolderForm();
|
resetFolderForm();
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
@@ -272,12 +409,46 @@ function deletePreset(preset: ExtendedPreset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleFavorite(preset: ExtendedPreset) {
|
function toggleFavorite(preset: ExtendedPreset) {
|
||||||
|
const wasFavorite = preset.isFavorite;
|
||||||
|
showNotification(wasFavorite ? '已取消收藏' : '已添加到收藏', 'success');
|
||||||
store.updateExtendedPreset(preset.id, { isFavorite: !preset.isFavorite });
|
store.updateExtendedPreset(preset.id, { isFavorite: !preset.isFavorite });
|
||||||
if (!preset.isFavorite) {
|
if (!preset.isFavorite) {
|
||||||
showNotification(`已添加到收藏`, 'success');
|
showNotification(`已添加到收藏`, 'success');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleFavorite(preset: ExtendedPreset) {
|
||||||
|
const nextIsFavorite = !preset.isFavorite;
|
||||||
|
store.updateExtendedPreset(preset.id, { isFavorite: nextIsFavorite });
|
||||||
|
showNotification(nextIsFavorite ? '已添加到收藏' : '已取消收藏', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReorderPresets(payload: { draggedId: string; targetId: string; side: 'before' | 'after' }) {
|
||||||
|
if (!isCustomSort.value) {
|
||||||
|
showNotification('请先切换到自定义排序后再拖拽', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visibleIds = filteredPresets.value.map(preset => preset.id);
|
||||||
|
const from = visibleIds.indexOf(payload.draggedId);
|
||||||
|
const target = visibleIds.indexOf(payload.targetId);
|
||||||
|
if (from === -1 || target === -1) return;
|
||||||
|
const reordered = [...visibleIds];
|
||||||
|
const [draggedId] = reordered.splice(from, 1);
|
||||||
|
if (!draggedId) return;
|
||||||
|
let insertAt = target;
|
||||||
|
if (payload.side === 'after') {
|
||||||
|
insertAt += from < target ? 0 : 1;
|
||||||
|
} else if (from < target) {
|
||||||
|
insertAt -= 1;
|
||||||
|
}
|
||||||
|
insertAt = Math.max(0, Math.min(insertAt, reordered.length));
|
||||||
|
reordered.splice(insertAt, 0, draggedId);
|
||||||
|
if (store.reorderExtendedPresets(reordered)) {
|
||||||
|
persistPresetManagerViewState();
|
||||||
|
showNotification('已调整预设顺序', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyPresetContent(preset: ExtendedPreset) {
|
async function copyPresetContent(preset: ExtendedPreset) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(preset.content);
|
await navigator.clipboard.writeText(preset.content);
|
||||||
@@ -681,6 +852,28 @@ function closeFolderDialog() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.initializeExtendedPresets();
|
store.initializeExtendedPresets();
|
||||||
|
nextTick(() => {
|
||||||
|
restorePresetManagerViewState();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
activeTab.value,
|
||||||
|
selectedType.value,
|
||||||
|
searchQuery.value,
|
||||||
|
selectedFolderId.value,
|
||||||
|
sortField.value,
|
||||||
|
sortDirection.value,
|
||||||
|
Array.from(expandedFolderIds.value).sort().join('|'),
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
persistPresetManagerViewState();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
persistPresetManagerViewState();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -693,6 +886,7 @@ onMounted(() => {
|
|||||||
<div class="pm-sidebar" :class="{ 'mobile-open': showMobileSidebar }">
|
<div class="pm-sidebar" :class="{ 'mobile-open': showMobileSidebar }">
|
||||||
<button v-if="showMobileSidebar" class="mobile-sidebar-close" @click="showMobileSidebar = false">×</button>
|
<button v-if="showMobileSidebar" class="mobile-sidebar-close" @click="showMobileSidebar = false">×</button>
|
||||||
<PresetSidebar
|
<PresetSidebar
|
||||||
|
ref="presetSidebarRef"
|
||||||
:folder-tree="folderTree"
|
:folder-tree="folderTree"
|
||||||
:selected-folder-id="selectedFolderId"
|
:selected-folder-id="selectedFolderId"
|
||||||
:expanded-ids="expandedFolderIds"
|
:expanded-ids="expandedFolderIds"
|
||||||
@@ -706,6 +900,7 @@ onMounted(() => {
|
|||||||
@edit-folder="editFolder"
|
@edit-folder="editFolder"
|
||||||
@delete-folder="deleteFolder"
|
@delete-folder="deleteFolder"
|
||||||
@share-folder="handleShareFolder"
|
@share-folder="handleShareFolder"
|
||||||
|
@view-state-change="persistPresetManagerViewState"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -713,18 +908,11 @@ onMounted(() => {
|
|||||||
<div class="pm-main">
|
<div class="pm-main">
|
||||||
<div class="pm-toolbar">
|
<div class="pm-toolbar">
|
||||||
<button class="btn-icon mobile-menu-btn" @click="showMobileSidebar = true">
|
<button class="btn-icon mobile-menu-btn" @click="showMobileSidebar = true">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<IconMenu width="24" height="24" />
|
||||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
||||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<IconSearch width="16" height="16" />
|
||||||
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<path d="m21 21-4.35-4.35" stroke="currentColor" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
<input v-model="searchQuery" placeholder="搜索预设..." />
|
<input v-model="searchQuery" placeholder="搜索预设..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -733,36 +921,45 @@ onMounted(() => {
|
|||||||
v-model="selectedType"
|
v-model="selectedType"
|
||||||
:options="filterOptions"
|
:options="filterOptions"
|
||||||
/>
|
/>
|
||||||
|
<div class="sort-controls">
|
||||||
|
<div class="sort-field-group">
|
||||||
|
<button
|
||||||
|
v-for="option in sortOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="sort-chip nav-btn"
|
||||||
|
:class="{ active: sortField === option.value }"
|
||||||
|
@click="setSortField(option.value)"
|
||||||
|
:title="option.label"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="sort-direction-btn nav-btn"
|
||||||
|
@click="toggleSortDirection"
|
||||||
|
:title="`切换为${sortDirection === 'asc' ? '倒序' : '正序'}`"
|
||||||
|
>
|
||||||
|
<IconSort width="14" height="14" />
|
||||||
|
<span>{{ sortDirectionLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-group">
|
<div class="action-group">
|
||||||
<button @click="createPreset" class="btn-primary">
|
<button @click="createPreset" class="btn-primary">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<IconPlus width="14" height="14" />
|
||||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
新建预设
|
新建预设
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="import-export">
|
<div class="import-export">
|
||||||
<button @click="openShareDialog()" class="btn-icon" title="云端分享/导入">
|
<button @click="openShareDialog()" class="btn-icon" title="云端分享/导入">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<IconShare width="16" height="16" />
|
||||||
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button @click="exportPresets" class="btn-icon" title="导出预设">
|
<button @click="exportPresets" class="btn-icon" title="导出预设">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<IconExport width="16" height="16" />
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<polyline points="7,10 12,15 17,10" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<label class="btn-icon" title="导入预设">
|
<label class="btn-icon" title="导入预设">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<IconImport width="16" height="16" />
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<polyline points="17,8 12,3 7,8" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<line x1="12" y1="3" x2="12" y2="15" stroke="currentColor" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
<input type="file" accept=".json" @change="importPresets" style="display: none;">
|
<input type="file" accept=".json" @change="importPresets" style="display: none;">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -770,21 +967,29 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pm-content-area">
|
<div class="pm-content-area">
|
||||||
|
<div class="sort-hint">
|
||||||
|
{{ isCustomSort ? '当前为自定义排序,可直接拖拽卡片调整顺序。' : '当前为只读排序视图,切回自定义排序后可继续拖拽。' }}
|
||||||
|
</div>
|
||||||
<PresetList
|
<PresetList
|
||||||
|
ref="presetListRef"
|
||||||
:presets="filteredPresets"
|
:presets="filteredPresets"
|
||||||
:search-query="searchQuery"
|
:search-query="searchQuery"
|
||||||
|
:reset-key="presetListResetKey"
|
||||||
|
:enable-reorder="isCustomSort"
|
||||||
@apply="applyPreset"
|
@apply="applyPreset"
|
||||||
@edit="editPreset"
|
@edit="editPreset"
|
||||||
@delete="deletePreset"
|
@delete="deletePreset"
|
||||||
@copy="copyPresetContent"
|
@copy="copyPresetContent"
|
||||||
@share="handleShare"
|
@share="handleShare"
|
||||||
@toggle-favorite="toggleFavorite"
|
@toggle-favorite="handleToggleFavorite"
|
||||||
|
@reorder="handleReorderPresets"
|
||||||
|
@view-state-change="persistPresetManagerViewState"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Preset Modal -->
|
<!-- Create/Edit Preset Modal -->
|
||||||
<div v-if="showCreateDialog" class="modal-overlay" @click.self="closePresetDialog">
|
<div v-if="showCreateDialog" class="modal-overlay">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>{{ editingPreset ? '编辑预设' : '新建预设' }}</h3>
|
<h3>{{ editingPreset ? '编辑预设' : '新建预设' }}</h3>
|
||||||
@@ -1108,6 +1313,8 @@ onMounted(() => {
|
|||||||
.filter-group {
|
.filter-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-select {
|
.type-select {
|
||||||
@@ -1118,6 +1325,66 @@ onMounted(() => {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-field-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-chip {
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: calc(var(--radius-md) - 4px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-chip:hover {
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-chip.active {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-direction-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-direction-btn:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.action-group {
|
.action-group {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1138,6 +1405,12 @@ onMounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-hint {
|
||||||
|
padding: 0.625rem 1rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed, nextTick, watch } from 'vue';
|
import { onMounted, onUnmounted, ref, computed, nextTick, watch } from 'vue';
|
||||||
import { usePromptStore, splitTokens, normalizeSymbols, parseDetailedToken, constructToken } from '../stores/promptStore';
|
import { usePromptStore, splitTokens, normalizeSymbols, parseDetailedToken, constructToken, toNumericForm, toBracketForm, formatWeight } from '../stores/promptStore';
|
||||||
import type { LangCode, PresetFolder } from '../types';
|
import type { LangCode, PresetFolder } from '../types';
|
||||||
import NotificationToast from './NotificationToast.vue';
|
import NotificationToast from './NotificationToast.vue';
|
||||||
import TranslationPopup from './TranslationPopup.vue';
|
import TranslationPopup from './TranslationPopup.vue';
|
||||||
@@ -37,8 +37,16 @@ let rafId: number | null = null;
|
|||||||
const DRAG_THRESHOLD = 3; // 像素阈值,避免误触
|
const DRAG_THRESHOLD = 3; // 像素阈值,避免误触
|
||||||
const editingIndex = ref<number | null>(null);
|
const editingIndex = ref<number | null>(null);
|
||||||
const presetName = ref('');
|
const presetName = ref('');
|
||||||
const selectedFolderId = ref<string>('');
|
// 跨页面切换保留所选文件夹(刷新后重置为默认)
|
||||||
|
const selectedFolderId = computed({
|
||||||
|
get: () => store.editorSelectedFolderId,
|
||||||
|
set: (v: string) => { store.editorSelectedFolderId = v; },
|
||||||
|
});
|
||||||
|
// 保存预设时是否同时标记为收藏
|
||||||
|
const saveAsFavorite = ref(false);
|
||||||
const viewMode = ref<'compact' | 'detail'>('compact');
|
const viewMode = ref<'compact' | 'detail'>('compact');
|
||||||
|
// 左侧光标所在的 token 序号,用于在右侧映射中定位高亮
|
||||||
|
const activeTokenIndex = ref<number | null>(null);
|
||||||
const showPresetDropdown = ref(false);
|
const showPresetDropdown = ref(false);
|
||||||
const showTranslationPopup = ref(false);
|
const showTranslationPopup = ref(false);
|
||||||
const translationTargetToken = ref<string | null>(null);
|
const translationTargetToken = ref<string | null>(null);
|
||||||
@@ -73,9 +81,13 @@ function handleClickOutside(event: Event) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
const defaultFolder = store.presetManagement?.settings?.defaultFolder;
|
// 仅首次进入时初始化为默认文件夹;后续页面切换保留用户上次的选择
|
||||||
if (defaultFolder) {
|
if (!store.editorFolderInitialized) {
|
||||||
selectedFolderId.value = defaultFolder;
|
const defaultFolder = store.presetManagement?.settings?.defaultFolder;
|
||||||
|
if (defaultFolder) {
|
||||||
|
store.editorSelectedFolderId = defaultFolder;
|
||||||
|
}
|
||||||
|
store.editorFolderInitialized = true;
|
||||||
}
|
}
|
||||||
store.searchQuery = ''; // Reset search query to ensure all tags are shown
|
store.searchQuery = ''; // Reset search query to ensure all tags are shown
|
||||||
});
|
});
|
||||||
@@ -134,7 +146,10 @@ const suggestions = ref<string[]>([]);
|
|||||||
const editSuggestions = ref<string[]>([]);
|
const editSuggestions = ref<string[]>([]);
|
||||||
const editorInputRef = ref<InstanceType<typeof EditorInput> | null>(null);
|
const editorInputRef = ref<InstanceType<typeof EditorInput> | null>(null);
|
||||||
const tokenMappingRef = ref<InstanceType<typeof TokenMappingPanel> | null>(null);
|
const tokenMappingRef = ref<InstanceType<typeof TokenMappingPanel> | null>(null);
|
||||||
const priorityStyle = ref<'{}' | '()' | '[]' | '<>' | 'suffix'>('{}');
|
// 优先级模式开关:false = 括号嵌套,true = 数字权重后缀
|
||||||
|
const numericMode = ref(false);
|
||||||
|
// 括号嵌套时使用的括号样式(默认圆括号)
|
||||||
|
const bracketStyle = ref<'()' | '{}' | '[]' | '<>'>('()');
|
||||||
const priorityStep = ref(0.1);
|
const priorityStep = ref(0.1);
|
||||||
function splitTokensLocal(txt: string): string[] {
|
function splitTokensLocal(txt: string): string[] {
|
||||||
return splitTokens(txt);
|
return splitTokens(txt);
|
||||||
@@ -166,25 +181,38 @@ function roundToDecimals(v: number, decimals: number): number {
|
|||||||
const m = Math.pow(10, decimals);
|
const m = Math.pow(10, decimals);
|
||||||
return Math.round(v * m) / m;
|
return Math.round(v * m) / m;
|
||||||
}
|
}
|
||||||
function adjustWeight(core: string, delta: number): string {
|
function stepDecimals(): number {
|
||||||
const idx = core.lastIndexOf(':');
|
const s = String(priorityStep.value);
|
||||||
let base = core;
|
return s.includes('.') ? (s.split('.')[1]?.length ?? 0) : 0;
|
||||||
let w: number | null = null;
|
}
|
||||||
if (idx > -1) {
|
|
||||||
const num = parseFloat(core.slice(idx + 1).trim());
|
|
||||||
if (!isNaN(num)) { base = core.slice(0, idx); w = num; }
|
|
||||||
}
|
|
||||||
const stepStr = String(priorityStep.value);
|
|
||||||
const decimals = stepStr.includes('.') ? stepStr.split('.')[1]!.length : 0;
|
|
||||||
const cur = w == null ? 1.0 : w;
|
|
||||||
let nw = cur + delta;
|
|
||||||
|
|
||||||
nw = roundToDecimals(nw, decimals);
|
// 数字权重模式:确保 () 容器,按步进调整显式权重(默认 1,即无后缀)
|
||||||
|
function adjustNumericWeight(token: string, deltaSteps: number): string {
|
||||||
|
const { core, weight, prefix, suffix } = parseDetailedToken(token);
|
||||||
|
if (prefix || suffix) return token;
|
||||||
|
const base = weight ?? 1;
|
||||||
|
let next = roundToDecimals(base + deltaSteps * priorityStep.value, stepDecimals());
|
||||||
|
if (next < 0) next = 0;
|
||||||
|
if (next === 1) return `(${core})`;
|
||||||
|
return `(${core}:${formatWeight(next)})`;
|
||||||
|
}
|
||||||
|
|
||||||
// If weight is 1, return base without suffix
|
// 括号嵌套模式:在最外层再套一层选定括号
|
||||||
if (nw === 1) return base;
|
function addBracketLayer(token: string): string {
|
||||||
|
const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(token);
|
||||||
|
const newWrappers = [bracketStyle.value, ...wrappers];
|
||||||
|
return constructToken(core, weight, newWrappers, prefix, suffix);
|
||||||
|
}
|
||||||
|
|
||||||
return base + ':' + nw;
|
// 括号嵌套模式:去掉最外层括号;若移除后无 () 承载裸权重则一并清除
|
||||||
|
function removeBracketLayer(token: string): string {
|
||||||
|
const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(token);
|
||||||
|
if (prefix || suffix) return token;
|
||||||
|
if (wrappers.length === 0) return core;
|
||||||
|
const newWrappers = wrappers.slice(1);
|
||||||
|
const hasParen = newWrappers.includes('()');
|
||||||
|
const keepWeight = weight !== undefined && weight !== 1 && hasParen ? weight : undefined;
|
||||||
|
return constructToken(core, keepWeight, newWrappers, prefix, suffix);
|
||||||
}
|
}
|
||||||
const text = ref('');
|
const text = ref('');
|
||||||
|
|
||||||
@@ -338,25 +366,11 @@ function replaceCnComma() { applyFullPrompt(normalizeSymbols(text.value)); }
|
|||||||
function formatPrompt() { applyFullPrompt(normalizePromptLocal(text.value)); }
|
function formatPrompt() { applyFullPrompt(normalizePromptLocal(text.value)); }
|
||||||
|
|
||||||
function unifyPriorityStyle() {
|
function unifyPriorityStyle() {
|
||||||
const tokens = splitTokens(text.value);
|
const list = splitTokens(text.value).map(token =>
|
||||||
const processed = tokens.map(token => {
|
numericMode.value ? toNumericForm(token) : toBracketForm(token)
|
||||||
const { core, weight, wrappers } = parseDetailedToken(token);
|
);
|
||||||
let result = core;
|
applyFullPrompt(list.join(', '));
|
||||||
let currentWrappers = [...wrappers];
|
showNotification(numericMode.value ? '已统一为数字权重' : '已统一为括号样式', 'success');
|
||||||
|
|
||||||
if (weight !== undefined && weight !== 1) {
|
|
||||||
const lastWrapper = currentWrappers[currentWrappers.length - 1];
|
|
||||||
if (lastWrapper === '()') {
|
|
||||||
currentWrappers.pop();
|
|
||||||
}
|
|
||||||
const wStr = Number.isInteger(weight) ? weight.toString() : weight.toFixed(2).replace(/\.?0+$/, '');
|
|
||||||
result = `(${result}:${wStr})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return store.wrapToken(result, currentWrappers);
|
|
||||||
});
|
|
||||||
applyFullPrompt(processed.join(', '));
|
|
||||||
showNotification('已统一优先级样式', 'success');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增功能方法
|
// 新增功能方法
|
||||||
@@ -416,34 +430,18 @@ function addWrapperToToken(index: number) {
|
|||||||
const tokens = splitTokensLocal(text.value);
|
const tokens = splitTokensLocal(text.value);
|
||||||
if (index < 0 || index >= tokens.length) return;
|
if (index < 0 || index >= tokens.length) return;
|
||||||
const token = tokens[index]!;
|
const token = tokens[index]!;
|
||||||
const parsed = store.parseTokenWrappers(token);
|
tokens[index] = numericMode.value ? adjustNumericWeight(token, +1) : addBracketLayer(token);
|
||||||
const core = parsed?.core ?? token;
|
|
||||||
const wrappers = parsed?.wrappers ?? [];
|
|
||||||
if (priorityStyle.value === 'suffix') {
|
|
||||||
const newCore = adjustWeight(core, +priorityStep.value);
|
|
||||||
tokens[index] = store.wrapToken(newCore, wrappers);
|
|
||||||
} else {
|
|
||||||
const newWrappers = [...wrappers, priorityStyle.value];
|
|
||||||
tokens[index] = store.wrapToken(core, newWrappers);
|
|
||||||
}
|
|
||||||
applyFullPrompt(tokens.join(', '));
|
applyFullPrompt(tokens.join(', '));
|
||||||
showNotification('已添加优先级', 'success');
|
showNotification(numericMode.value ? '已提升权重' : '已添加优先级', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeWrapperFromToken(index: number) {
|
function removeWrapperFromToken(index: number) {
|
||||||
const tokens = splitTokensLocal(text.value);
|
const tokens = splitTokensLocal(text.value);
|
||||||
if (index < 0 || index >= tokens.length) return;
|
if (index < 0 || index >= tokens.length) return;
|
||||||
const token = tokens[index]!;
|
const token = tokens[index]!;
|
||||||
const { core, wrappers } = store.parseTokenWrappers(token);
|
tokens[index] = numericMode.value ? adjustNumericWeight(token, -1) : removeBracketLayer(token);
|
||||||
if (priorityStyle.value === 'suffix') {
|
|
||||||
const newCore = adjustWeight(core, -priorityStep.value);
|
|
||||||
tokens[index] = store.wrapToken(newCore, wrappers);
|
|
||||||
} else if (wrappers.length > 0) {
|
|
||||||
const newWrappers = wrappers.slice(0, -1);
|
|
||||||
tokens[index] = store.wrapToken(core, newWrappers);
|
|
||||||
}
|
|
||||||
applyFullPrompt(tokens.join(', '));
|
applyFullPrompt(tokens.join(', '));
|
||||||
showNotification('已调整优先级', 'success');
|
showNotification(numericMode.value ? '已降低权重' : '已移除优先级', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTokenWrapperInfo(token: string) {
|
function getTokenWrapperInfo(token: string) {
|
||||||
@@ -784,10 +782,16 @@ function savePreset() {
|
|||||||
type: 'positive',
|
type: 'positive',
|
||||||
content: store.promptText,
|
content: store.promptText,
|
||||||
description: '从编辑器快速保存',
|
description: '从编辑器快速保存',
|
||||||
folderId: folderId
|
folderId: folderId,
|
||||||
|
isFavorite: saveAsFavorite.value
|
||||||
});
|
});
|
||||||
|
|
||||||
showNotification(`预设「${name}」已保存到预设管理`, 'success');
|
showNotification(
|
||||||
|
saveAsFavorite.value
|
||||||
|
? `预设「${name}」已保存并收藏`
|
||||||
|
: `预设「${name}」已保存到预设管理`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
presetName.value = '';
|
presetName.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,6 +831,7 @@ const unmappedTokens = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function isUnmapped(key: string): boolean {
|
function isUnmapped(key: string): boolean {
|
||||||
|
void store.mappingVersion; // 追踪映射变更以触发响应式刷新
|
||||||
const { core } = parseDetailedToken(key);
|
const { core } = parseDetailedToken(key);
|
||||||
const tag = store.getTagByKey(core);
|
const tag = store.getTagByKey(core);
|
||||||
return !tag || !tag.translation?.[selectedLang.value];
|
return !tag || !tag.translation?.[selectedLang.value];
|
||||||
@@ -840,6 +845,7 @@ function handleApplyTranslation(results: { key: string; trans: string }[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayTrans(key: string): string {
|
function displayTrans(key: string): string {
|
||||||
|
void store.mappingVersion; // 追踪映射变更以触发响应式刷新
|
||||||
const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(key);
|
const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(key);
|
||||||
const tag = store.getTagByKey(core);
|
const tag = store.getTagByKey(core);
|
||||||
const translatedCore = tag?.translation?.[selectedLang.value] ?? tag?.key ?? core;
|
const translatedCore = tag?.translation?.[selectedLang.value] ?? tag?.key ?? core;
|
||||||
@@ -857,21 +863,24 @@ function isRemoveDisabled(token: string): boolean {
|
|||||||
<div class="pe-root">
|
<div class="pe-root">
|
||||||
<EditorToolbar :languages="store.languages as LangCode[]" v-model:selected-lang="selectedLang"
|
<EditorToolbar :languages="store.languages as LangCode[]" v-model:selected-lang="selectedLang"
|
||||||
v-model:preset-name="presetName" v-model:selected-folder-id="selectedFolderId"
|
v-model:preset-name="presetName" v-model:selected-folder-id="selectedFolderId"
|
||||||
|
v-model:save-as-favorite="saveAsFavorite"
|
||||||
v-model:show-preset-dropdown="showPresetDropdown" :folder-tree="folderTree" :flattened-folders="flattenedFolders"
|
v-model:show-preset-dropdown="showPresetDropdown" :folder-tree="folderTree" :flattened-folders="flattenedFolders"
|
||||||
@copy="copyLeft" @save-preset="savePreset" @preset-load="handlePresetLoad" @preset-save="handlePresetSave"
|
@copy="copyLeft" @save-preset="savePreset" @preset-load="handlePresetLoad" @preset-save="handlePresetSave"
|
||||||
@preset-delete="handlePresetDelete" @preset-rename="handlePresetRename" />
|
@preset-delete="handlePresetDelete" @preset-rename="handlePresetRename" />
|
||||||
|
|
||||||
<div class="pe-main">
|
<div class="pe-main">
|
||||||
<EditorInput ref="editorInputRef" v-model:text="text" v-model:priority-style="priorityStyle"
|
<EditorInput ref="editorInputRef" v-model:text="text" v-model:numeric-mode="numericMode"
|
||||||
v-model:priority-step="priorityStep" :suggestions="suggestions"
|
v-model:bracket-style="bracketStyle" v-model:priority-step="priorityStep" :suggestions="suggestions"
|
||||||
:get-suggestions="(prefix, limit) => store.getSuggestions(prefix, limit)"
|
:get-suggestions="(prefix, limit) => store.getSuggestions(prefix, limit)"
|
||||||
@update-suggestions="updateSuggestionsFromText" @copy="copyLeft" @replace-cn-comma="replaceCnComma"
|
@update-suggestions="updateSuggestionsFromText" @copy="copyLeft" @replace-cn-comma="replaceCnComma"
|
||||||
@format-prompt="formatPrompt" @unify-priority="unifyPriorityStyle" @toggle-underscore="toggleUnderscoreSpace"
|
@format-prompt="formatPrompt" @unify-priority="unifyPriorityStyle" @toggle-underscore="toggleUnderscoreSpace"
|
||||||
@add-tag="handleAddTag" @drag-tag-start="handleQuickAddDragStart" @drag-tag-end="handleQuickAddDragEnd" />
|
@add-tag="handleAddTag" @drag-tag-start="handleQuickAddDragStart" @drag-tag-end="handleQuickAddDragEnd"
|
||||||
|
@locate-token="(i) => activeTokenIndex = i >= 0 ? i : null" />
|
||||||
|
|
||||||
<TokenMappingPanel ref="tokenMappingRef" :tokens="tokens" :selected-lang="selectedLang"
|
<TokenMappingPanel ref="tokenMappingRef" :tokens="tokens" :selected-lang="selectedLang"
|
||||||
v-model:view-mode="viewMode" :dragging-index="draggingIndex" :over-index="overIndex" :insert-side="insertSide"
|
v-model:view-mode="viewMode" :dragging-index="draggingIndex" :over-index="overIndex" :insert-side="insertSide"
|
||||||
:is-dragging="isDragging" :edit-suggestions="editSuggestions" :priority-style="priorityStyle"
|
:is-dragging="isDragging" :edit-suggestions="editSuggestions" :numeric-mode="numericMode"
|
||||||
|
:active-index="activeTokenIndex"
|
||||||
:display-trans="displayTrans" :is-unmapped="isUnmapped" :get-token-wrapper-info="getTokenWrapperInfo"
|
:display-trans="displayTrans" :is-unmapped="isUnmapped" :get-token-wrapper-info="getTokenWrapperInfo"
|
||||||
:has-weight-suffix="hasWeightSuffix" :get-suggestions="(prefix, limit) => store.getSuggestions(prefix, limit)"
|
:has-weight-suffix="hasWeightSuffix" :get-suggestions="(prefix, limit) => store.getSuggestions(prefix, limit)"
|
||||||
@pointer-down="onPointerDown" @panel-dragover="handlePanelDragOver" @panel-dragleave="handlePanelDragLeave"
|
@pointer-down="onPointerDown" @panel-dragover="handlePanelDragOver" @panel-dragleave="handlePanelDragLeave"
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, reactive, nextTick, watch } from 'vue';
|
||||||
import { usePromptStore } from '../stores/promptStore';
|
import { usePromptStore } from '../stores/promptStore';
|
||||||
import type { LangCode, PromptGroup, PromptTag } from '../types';
|
import type { LangCode, PromptTag } from '../types';
|
||||||
|
|
||||||
const store = usePromptStore();
|
const store = usePromptStore();
|
||||||
const draggingIndex = ref<number | null>(null);
|
const draggingIndex = ref<number | null>(null);
|
||||||
const overIndex = ref<number | null>(null);
|
const overIndex = ref<number | null>(null);
|
||||||
|
const keyInputRefs = ref<HTMLInputElement[]>([]);
|
||||||
|
const tagIds = new WeakMap<PromptTag, string>();
|
||||||
|
let tagIdSeq = 0;
|
||||||
|
|
||||||
|
type TagDraft = {
|
||||||
|
key: string;
|
||||||
|
translation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drafts = reactive<Record<string, TagDraft>>({});
|
||||||
|
const translating = reactive<Record<string, boolean>>({});
|
||||||
|
const translateErrors = reactive<Record<string, string>>({});
|
||||||
|
|
||||||
const languages = computed(() => store.languages);
|
const languages = computed(() => store.languages);
|
||||||
const selectedLang = computed({
|
const selectedLang = computed({
|
||||||
@@ -18,6 +30,37 @@ const currentGroup = computed(() => store.currentGroup);
|
|||||||
const filteredTags = computed(() => store.filteredTags);
|
const filteredTags = computed(() => store.filteredTags);
|
||||||
const isSearching = computed(() => store.searchQuery.trim().length > 0);
|
const isSearching = computed(() => store.searchQuery.trim().length > 0);
|
||||||
|
|
||||||
|
watch(selectedLang, () => {
|
||||||
|
for (const tag of filteredTags.value) {
|
||||||
|
const id = getTagId(tag);
|
||||||
|
drafts[id] = {
|
||||||
|
key: tag.key,
|
||||||
|
translation: displayTranslation(tag),
|
||||||
|
};
|
||||||
|
delete translateErrors[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getTagId(tag: PromptTag): string {
|
||||||
|
let id = tagIds.get(tag);
|
||||||
|
if (!id) {
|
||||||
|
id = `tag_${++tagIdSeq}`;
|
||||||
|
tagIds.set(tag, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDraft(tag: PromptTag): TagDraft {
|
||||||
|
const id = getTagId(tag);
|
||||||
|
if (!drafts[id]) {
|
||||||
|
drafts[id] = {
|
||||||
|
key: tag.key,
|
||||||
|
translation: displayTranslation(tag),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return drafts[id];
|
||||||
|
}
|
||||||
|
|
||||||
function onDragStart(index: number) {
|
function onDragStart(index: number) {
|
||||||
if (isSearching.value) return;
|
if (isSearching.value) return;
|
||||||
draggingIndex.value = index;
|
draggingIndex.value = index;
|
||||||
@@ -42,22 +85,40 @@ function displayTranslation(tag: PromptTag): string {
|
|||||||
return tag.translation?.[selectedLang.value] ?? tag.key;
|
return tag.translation?.[selectedLang.value] ?? tag.key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateKey(tag: PromptTag, val: string) {
|
function commitKey(tag: PromptTag) {
|
||||||
const gid = currentGroup.value?.id;
|
const gid = currentGroup.value?.id;
|
||||||
if (!gid) return;
|
if (!gid) return;
|
||||||
store.updateTagKey(gid, tag.key, val);
|
const draft = getDraft(tag);
|
||||||
|
const nextKey = draft.key.trim();
|
||||||
|
if (!nextKey || nextKey === tag.key) {
|
||||||
|
draft.key = tag.key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldKey = tag.key;
|
||||||
|
store.updateTagKey(gid, oldKey, nextKey);
|
||||||
|
if (tag.key !== oldKey) {
|
||||||
|
if (draft.translation === oldKey) {
|
||||||
|
draft.translation = nextKey;
|
||||||
|
}
|
||||||
|
draft.key = tag.key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTrans(tag: PromptTag, val: string) {
|
function commitTrans(tag: PromptTag) {
|
||||||
const gid = currentGroup.value?.id;
|
const gid = currentGroup.value?.id;
|
||||||
if (!gid) return;
|
if (!gid) return;
|
||||||
store.setTranslation(gid, tag.key, selectedLang.value, val);
|
const draft = getDraft(tag);
|
||||||
|
store.setTranslation(gid, tag.key, selectedLang.value, draft.translation.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag() {
|
async function addTag() {
|
||||||
const gid = currentGroup.value?.id;
|
const gid = currentGroup.value?.id;
|
||||||
if (!gid) return;
|
if (!gid) return;
|
||||||
|
store.setSearch('');
|
||||||
store.addTag(gid);
|
store.addTag(gid);
|
||||||
|
await nextTick();
|
||||||
|
keyInputRefs.value[0]?.focus();
|
||||||
|
keyInputRefs.value[0]?.select();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTag(tag: PromptTag) {
|
function removeTag(tag: PromptTag) {
|
||||||
@@ -75,9 +136,50 @@ function confirmRemoveTag(tag: PromptTag) {
|
|||||||
function toggleHidden(tag: PromptTag) {
|
function toggleHidden(tag: PromptTag) {
|
||||||
const gid = currentGroup.value?.id;
|
const gid = currentGroup.value?.id;
|
||||||
if (!gid) return;
|
if (!gid) return;
|
||||||
|
commitKey(tag);
|
||||||
store.toggleHidden(gid, tag.key);
|
store.toggleHidden(gid, tag.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanPromptKey(key: string): string {
|
||||||
|
return key
|
||||||
|
.replace(/^[([{<]+/, '')
|
||||||
|
.replace(/[)\]}>]+$/, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function translateTag(tag: PromptTag) {
|
||||||
|
const gid = currentGroup.value?.id;
|
||||||
|
if (!gid) return;
|
||||||
|
commitKey(tag);
|
||||||
|
|
||||||
|
const id = getTagId(tag);
|
||||||
|
const key = tag.key.trim();
|
||||||
|
if (!key || translating[id]) return;
|
||||||
|
|
||||||
|
translating[id] = true;
|
||||||
|
delete translateErrors[id];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = selectedLang.value === 'zh_CN' ? 'zh' : selectedLang.value;
|
||||||
|
const text = cleanPromptKey(key);
|
||||||
|
const url = `https://sywb.top/api/translate2?text=${encodeURIComponent(text)}&sourceLang=auto&targetLang=${target}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success || !data.translation) {
|
||||||
|
throw new Error(data.message || '翻译失败');
|
||||||
|
}
|
||||||
|
const translation = String(data.translation).trim();
|
||||||
|
const draft = getDraft(tag);
|
||||||
|
draft.translation = translation;
|
||||||
|
store.setTranslation(gid, key, selectedLang.value, translation);
|
||||||
|
} catch (error: any) {
|
||||||
|
translateErrors[id] = error?.message || '翻译失败';
|
||||||
|
} finally {
|
||||||
|
translating[id] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function exportAll() {
|
function exportAll() {
|
||||||
const json = store.exportToJson();
|
const json = store.exportToJson();
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
@@ -244,7 +346,7 @@ function resetDefault() {
|
|||||||
|
|
||||||
<div v-else class="pm-tags-wrapper">
|
<div v-else class="pm-tags-wrapper">
|
||||||
<TransitionGroup name="list" tag="ul" class="pm-tags-list" :css="!isSearching">
|
<TransitionGroup name="list" tag="ul" class="pm-tags-list" :css="!isSearching">
|
||||||
<li v-for="(t, ti) in filteredTags" :key="t.key + '_' + ti"
|
<li v-for="(t, ti) in filteredTags" :key="getTagId(t)"
|
||||||
class="pm-tag-item"
|
class="pm-tag-item"
|
||||||
:draggable="!isSearching"
|
:draggable="!isSearching"
|
||||||
@dragstart="onDragStart(ti)"
|
@dragstart="onDragStart(ti)"
|
||||||
@@ -271,22 +373,47 @@ function resetDefault() {
|
|||||||
<div class="pm-input-group">
|
<div class="pm-input-group">
|
||||||
<label class="pm-input-label">Key</label>
|
<label class="pm-input-label">Key</label>
|
||||||
<input class="pm-input pm-key-input"
|
<input class="pm-input pm-key-input"
|
||||||
:value="t.key"
|
ref="keyInputRefs"
|
||||||
@input="updateKey(t, ($event.target as HTMLInputElement).value)"
|
v-model="getDraft(t).key"
|
||||||
|
@blur="commitKey(t)"
|
||||||
|
@keydown.enter.prevent="commitKey(t)"
|
||||||
placeholder="提示词 Key"
|
placeholder="提示词 Key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pm-input-group">
|
<div class="pm-input-group">
|
||||||
<label class="pm-input-label">Translation</label>
|
<div class="pm-input-label-row">
|
||||||
|
<label class="pm-input-label">Translation</label>
|
||||||
|
<span v-if="translateErrors[getTagId(t)]" class="pm-input-error" :title="translateErrors[getTagId(t)]">
|
||||||
|
{{ translateErrors[getTagId(t)] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<input class="pm-input pm-trans-input"
|
<input class="pm-input pm-trans-input"
|
||||||
:value="displayTranslation(t)"
|
v-model="getDraft(t).translation"
|
||||||
@input="updateTrans(t, ($event.target as HTMLInputElement).value)"
|
@blur="commitTrans(t)"
|
||||||
|
@keydown.enter.prevent="commitTrans(t)"
|
||||||
placeholder="翻译内容"
|
placeholder="翻译内容"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pm-tag-actions">
|
<div class="pm-tag-actions">
|
||||||
|
<button
|
||||||
|
class="pm-icon-btn pm-btn-translate"
|
||||||
|
:class="{ loading: translating[getTagId(t)] }"
|
||||||
|
:disabled="translating[getTagId(t)]"
|
||||||
|
@click="translateTag(t)"
|
||||||
|
title="自动翻译当前提示词"
|
||||||
|
>
|
||||||
|
<svg v-if="!translating[getTagId(t)]" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="m5 8 6 6"></path>
|
||||||
|
<path d="m4 14 6-6 2-3"></path>
|
||||||
|
<path d="M2 5h12"></path>
|
||||||
|
<path d="M7 2h1"></path>
|
||||||
|
<path d="m22 22-5-10-5 10"></path>
|
||||||
|
<path d="M14 18h6"></path>
|
||||||
|
</svg>
|
||||||
|
<span v-else class="pm-mini-spinner"></span>
|
||||||
|
</button>
|
||||||
<button class="pm-icon-btn" @click="toggleHidden(t)" :title="t.hidden ? '显示' : '隐藏'">
|
<button class="pm-icon-btn" @click="toggleHidden(t)" :title="t.hidden ? '显示' : '隐藏'">
|
||||||
<svg v-if="!t.hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg v-if="!t.hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||||
@@ -698,6 +825,24 @@ function resetDefault() {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pm-input-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-input-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pm-input {
|
.pm-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -741,6 +886,35 @@ function resetDefault() {
|
|||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pm-icon-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-btn-translate {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-btn-translate:hover:not(:disabled),
|
||||||
|
.pm-btn-translate.loading {
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pm-mini-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pm-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pm-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.pm-btn-delete:hover {
|
.pm-btn-delete:hover {
|
||||||
background-color: #fef2f2;
|
background-color: #fef2f2;
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { usePromptStore } from '../stores/promptStore';
|
import { usePromptStore } from '../stores/promptStore';
|
||||||
import type { PromptTag } from '../types';
|
import type { PromptTag } from '../types';
|
||||||
|
|
||||||
@@ -13,23 +13,149 @@ const store = usePromptStore();
|
|||||||
const categories = computed(() => store.categories);
|
const categories = computed(() => store.categories);
|
||||||
const currentCategory = computed(() => store.currentCategory);
|
const currentCategory = computed(() => store.currentCategory);
|
||||||
const currentGroup = computed(() => store.currentGroup);
|
const currentGroup = computed(() => store.currentGroup);
|
||||||
const filteredTags = computed(() => store.filteredTags);
|
|
||||||
const selectedLang = computed(() => store.selectedLang);
|
const selectedLang = computed(() => store.selectedLang);
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
const QUICK_ADD_STATE_KEY = 'prompt-quick-add-view-state';
|
||||||
|
const QUICK_ADD_RECENT_KEY = 'prompt-quick-add-recent-tags';
|
||||||
|
const QUICK_ADD_FAVORITE_KEY = 'prompt-quick-add-favorite-tags';
|
||||||
const visibleCount = ref(PAGE_SIZE);
|
const visibleCount = ref(PAGE_SIZE);
|
||||||
const tagsContainer = ref<HTMLElement | null>(null);
|
const tagsContainer = ref<HTMLElement | null>(null);
|
||||||
const draggedTagKey = ref<string | null>(null);
|
const draggedTagKey = ref<string | null>(null);
|
||||||
|
const isRestoringState = ref(false);
|
||||||
|
const localSearch = ref('');
|
||||||
|
const recentKeys = ref<string[]>([]);
|
||||||
|
const favoriteKeys = ref<string[]>([]);
|
||||||
|
|
||||||
const visibleTags = computed(() => {
|
type QuickAddItem = {
|
||||||
return filteredTags.value.slice(0, visibleCount.value);
|
key: string;
|
||||||
|
translation: string;
|
||||||
|
categoryName?: string;
|
||||||
|
groupName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeQuery(value: string) {
|
||||||
|
return value.trim().toLowerCase().replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(tag: PromptTag, query: string, queryNorm: string) {
|
||||||
|
const translation = tag.translation?.[selectedLang.value] ?? tag.key;
|
||||||
|
const keyLower = tag.key.toLowerCase();
|
||||||
|
const keyNorm = keyLower.replace(/_/g, ' ');
|
||||||
|
const transLower = translation.toLowerCase();
|
||||||
|
const transNorm = transLower.replace(/_/g, ' ');
|
||||||
|
return (
|
||||||
|
keyLower.includes(query) ||
|
||||||
|
keyNorm.includes(queryNorm) ||
|
||||||
|
transLower.includes(query) ||
|
||||||
|
transNorm.includes(queryNorm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGroupItems = computed<QuickAddItem[]>(() => {
|
||||||
|
const group = currentGroup.value;
|
||||||
|
if (!group) return [];
|
||||||
|
return group.tags.map(tag => ({
|
||||||
|
key: tag.key,
|
||||||
|
translation: tag.translation?.[selectedLang.value] ?? tag.key,
|
||||||
|
categoryName: currentCategory.value?.name,
|
||||||
|
groupName: group.name,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => filteredTags.value, () => {
|
const searchResults = computed<QuickAddItem[]>(() => {
|
||||||
|
const query = localSearch.value.trim().toLowerCase();
|
||||||
|
const queryNorm = normalizeQuery(localSearch.value);
|
||||||
|
if (!query) return [];
|
||||||
|
const deduped = new Map<string, QuickAddItem>();
|
||||||
|
for (const category of categories.value) {
|
||||||
|
for (const group of category.groups) {
|
||||||
|
for (const tag of group.tags) {
|
||||||
|
if (!matchesQuery(tag, query, queryNorm)) continue;
|
||||||
|
if (deduped.has(tag.key)) continue;
|
||||||
|
deduped.set(tag.key, {
|
||||||
|
key: tag.key,
|
||||||
|
translation: tag.translation?.[selectedLang.value] ?? tag.key,
|
||||||
|
categoryName: category.name,
|
||||||
|
groupName: group.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(deduped.values()).sort((a, b) => {
|
||||||
|
const af = favoriteKeys.value.includes(a.key) ? 0 : 1;
|
||||||
|
const bf = favoriteKeys.value.includes(b.key) ? 0 : 1;
|
||||||
|
if (af !== bf) return af - bf;
|
||||||
|
return a.translation.localeCompare(b.translation, 'zh-CN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const allVisibleItems = computed<QuickAddItem[]>(() => {
|
||||||
|
return localSearch.value.trim() ? searchResults.value : currentGroupItems.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleItems = computed(() => {
|
||||||
|
return allVisibleItems.value.slice(0, visibleCount.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentItems = computed<QuickAddItem[]>(() => {
|
||||||
|
const tagMap = new Map<string, QuickAddItem>();
|
||||||
|
for (const category of categories.value) {
|
||||||
|
for (const group of category.groups) {
|
||||||
|
for (const tag of group.tags) {
|
||||||
|
if (!tagMap.has(tag.key)) {
|
||||||
|
tagMap.set(tag.key, {
|
||||||
|
key: tag.key,
|
||||||
|
translation: tag.translation?.[selectedLang.value] ?? tag.key,
|
||||||
|
categoryName: category.name,
|
||||||
|
groupName: group.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recentKeys.value
|
||||||
|
.map(key => tagMap.get(key))
|
||||||
|
.filter((item): item is QuickAddItem => !!item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const favoriteItems = computed<QuickAddItem[]>(() => {
|
||||||
|
const tagMap = new Map<string, QuickAddItem>();
|
||||||
|
for (const category of categories.value) {
|
||||||
|
for (const group of category.groups) {
|
||||||
|
for (const tag of group.tags) {
|
||||||
|
if (!tagMap.has(tag.key)) {
|
||||||
|
tagMap.set(tag.key, {
|
||||||
|
key: tag.key,
|
||||||
|
translation: tag.translation?.[selectedLang.value] ?? tag.key,
|
||||||
|
categoryName: category.name,
|
||||||
|
groupName: group.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return favoriteKeys.value
|
||||||
|
.map(key => tagMap.get(key))
|
||||||
|
.filter((item): item is QuickAddItem => !!item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const panelSummary = computed(() => {
|
||||||
|
if (localSearch.value.trim()) {
|
||||||
|
return `全局搜索结果 ${searchResults.value.length} 个`;
|
||||||
|
}
|
||||||
|
const category = currentCategory.value?.name ?? '未选择分类';
|
||||||
|
const group = currentGroup.value?.name ?? '未选择分组';
|
||||||
|
return `${category} / ${group} · ${currentGroupItems.value.length} 个`;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => [allVisibleItems.value.length, localSearch.value], () => {
|
||||||
|
if (isRestoringState.value) return;
|
||||||
visibleCount.value = PAGE_SIZE;
|
visibleCount.value = PAGE_SIZE;
|
||||||
if (tagsContainer.value) {
|
if (tagsContainer.value) {
|
||||||
tagsContainer.value.scrollTop = 0;
|
tagsContainer.value.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
persistQuickAddState();
|
||||||
});
|
});
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
@@ -37,23 +163,27 @@ function onScroll() {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
// Simple infinite scroll trigger
|
// Simple infinite scroll trigger
|
||||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
|
||||||
if (visibleCount.value < filteredTags.value.length) {
|
if (visibleCount.value < allVisibleItems.value.length) {
|
||||||
visibleCount.value += PAGE_SIZE;
|
visibleCount.value += PAGE_SIZE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
persistQuickAddState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCategory(index: number) {
|
function selectCategory(index: number) {
|
||||||
store.selectCategory(index);
|
store.selectCategory(index);
|
||||||
|
persistQuickAddState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectGroup(index: number) {
|
function selectGroup(index: number) {
|
||||||
store.selectGroup(index);
|
store.selectGroup(index);
|
||||||
|
persistQuickAddState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTagClick(tag: PromptTag) {
|
function onTagClick(tag: PromptTag) {
|
||||||
if (draggedTagKey.value === tag.key) return;
|
if (draggedTagKey.value === tag.key) return;
|
||||||
emit('add-tag', tag.key);
|
emit('add-tag', tag.key);
|
||||||
|
rememberRecentTag(tag.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTagDragStart(tag: PromptTag, event: DragEvent) {
|
function onTagDragStart(tag: PromptTag, event: DragEvent) {
|
||||||
@@ -72,13 +202,182 @@ function onTagDragEnd() {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayTrans(tag: PromptTag) {
|
function onQuickAddItemClick(item: QuickAddItem) {
|
||||||
return tag.translation?.[selectedLang.value] ?? tag.key;
|
onTagClick({ key: item.key, translation: { [selectedLang.value]: item.translation } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onQuickAddItemDragStart(item: QuickAddItem, event: DragEvent) {
|
||||||
|
onTagDragStart({ key: item.key, translation: { [selectedLang.value]: item.translation } }, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberRecentTag(tagKey: string) {
|
||||||
|
recentKeys.value = [tagKey, ...recentKeys.value.filter(key => key !== tagKey)].slice(0, 12);
|
||||||
|
persistRecentTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFavorite(tagKey: string) {
|
||||||
|
return favoriteKeys.value.includes(tagKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavorite(tagKey: string) {
|
||||||
|
if (isFavorite(tagKey)) {
|
||||||
|
favoriteKeys.value = favoriteKeys.value.filter(key => key !== tagKey);
|
||||||
|
} else {
|
||||||
|
favoriteKeys.value = [tagKey, ...favoriteKeys.value].slice(0, 30);
|
||||||
|
}
|
||||||
|
persistFavoriteTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
localSearch.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRecentTags() {
|
||||||
|
window.localStorage.setItem(QUICK_ADD_RECENT_KEY, JSON.stringify(recentKeys.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistFavoriteTags() {
|
||||||
|
window.localStorage.setItem(QUICK_ADD_FAVORITE_KEY, JSON.stringify(favoriteKeys.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreRecentTags() {
|
||||||
|
const raw = window.localStorage.getItem(QUICK_ADD_RECENT_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
recentKeys.value = parsed.filter((value): value is string => typeof value === 'string').slice(0, 12);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
recentKeys.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFavoriteTags() {
|
||||||
|
const raw = window.localStorage.getItem(QUICK_ADD_FAVORITE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
favoriteKeys.value = parsed.filter((value): value is string => typeof value === 'string').slice(0, 30);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
favoriteKeys.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistQuickAddState() {
|
||||||
|
const payload = {
|
||||||
|
categoryIndex: store.selectedCategoryIndex,
|
||||||
|
groupIndex: store.selectedGroupIndex,
|
||||||
|
visibleCount: visibleCount.value,
|
||||||
|
scrollTop: tagsContainer.value?.scrollTop ?? 0,
|
||||||
|
search: localSearch.value,
|
||||||
|
};
|
||||||
|
window.sessionStorage.setItem(QUICK_ADD_STATE_KEY, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreQuickAddState() {
|
||||||
|
const raw = window.sessionStorage.getItem(QUICK_ADD_STATE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(raw) as {
|
||||||
|
categoryIndex?: number;
|
||||||
|
groupIndex?: number;
|
||||||
|
visibleCount?: number;
|
||||||
|
scrollTop?: number;
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
isRestoringState.value = true;
|
||||||
|
const categoryCount = categories.value.length;
|
||||||
|
const nextCategoryIndex = Math.min(Math.max(state.categoryIndex ?? 0, 0), Math.max(categoryCount - 1, 0));
|
||||||
|
store.selectCategory(nextCategoryIndex);
|
||||||
|
const groupCount = currentCategory.value?.groups.length ?? 0;
|
||||||
|
const nextGroupIndex = Math.min(Math.max(state.groupIndex ?? 0, 0), Math.max(groupCount - 1, 0));
|
||||||
|
store.selectGroup(nextGroupIndex);
|
||||||
|
localSearch.value = state.search ?? '';
|
||||||
|
visibleCount.value = Math.max(PAGE_SIZE, state.visibleCount ?? PAGE_SIZE);
|
||||||
|
await nextTick();
|
||||||
|
if (tagsContainer.value) {
|
||||||
|
tagsContainer.value.scrollTop = Math.max(0, state.scrollTop ?? 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore broken persisted state and continue with defaults.
|
||||||
|
} finally {
|
||||||
|
isRestoringState.value = false;
|
||||||
|
persistQuickAddState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [store.selectedCategoryIndex, store.selectedGroupIndex, visibleCount.value],
|
||||||
|
() => {
|
||||||
|
if (!isRestoringState.value) {
|
||||||
|
persistQuickAddState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
restoreFavoriteTags();
|
||||||
|
restoreRecentTags();
|
||||||
|
restoreQuickAddState();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
persistQuickAddState();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pqa-root">
|
<div class="pqa-root">
|
||||||
|
<div class="pqa-toolbar">
|
||||||
|
<div class="pqa-search-wrap">
|
||||||
|
<input
|
||||||
|
v-model="localSearch"
|
||||||
|
class="pqa-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="搜索提示词或翻译..."
|
||||||
|
/>
|
||||||
|
<button v-if="localSearch" class="pqa-search-clear" @click="clearSearch" title="清空搜索">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pqa-summary">{{ panelSummary }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="favoriteItems.length" class="pqa-favorites">
|
||||||
|
<div class="pqa-favorites-title">收藏词</div>
|
||||||
|
<div class="pqa-chip-list">
|
||||||
|
<div
|
||||||
|
v-for="item in favoriteItems"
|
||||||
|
:key="'favorite_' + item.key"
|
||||||
|
class="pqa-chip-card"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="pqa-chip pqa-chip-favorite"
|
||||||
|
draggable="true"
|
||||||
|
@click="onQuickAddItemClick(item)"
|
||||||
|
@dragstart="onQuickAddItemDragStart(item, $event)"
|
||||||
|
@dragend="onTagDragEnd"
|
||||||
|
:title="item.key"
|
||||||
|
>
|
||||||
|
{{ item.translation }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="pqa-fav-btn active"
|
||||||
|
type="button"
|
||||||
|
title="取消收藏"
|
||||||
|
@click.stop="toggleFavorite(item.key)"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.55L12 17.52 6.12 20.61l1.12-6.55L2.48 9.42l6.58-.96L12 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Categories -->
|
<!-- Categories -->
|
||||||
<div class="pqa-categories">
|
<div class="pqa-categories">
|
||||||
<button v-for="(c, i) in categories" :key="c.id" class="pqa-tab"
|
<button v-for="(c, i) in categories" :key="c.id" class="pqa-tab"
|
||||||
@@ -101,18 +400,67 @@ function displayTrans(tag: PromptTag) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recentItems.length" class="pqa-recent">
|
||||||
|
<div class="pqa-recent-title">最近使用</div>
|
||||||
|
<div class="pqa-chip-list">
|
||||||
|
<div
|
||||||
|
v-for="item in recentItems"
|
||||||
|
:key="'recent_' + item.key"
|
||||||
|
class="pqa-chip-card"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="pqa-chip"
|
||||||
|
draggable="true"
|
||||||
|
@click="onQuickAddItemClick(item)"
|
||||||
|
@dragstart="onQuickAddItemDragStart(item, $event)"
|
||||||
|
@dragend="onTagDragEnd"
|
||||||
|
:title="item.key"
|
||||||
|
>
|
||||||
|
{{ item.translation }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="pqa-fav-btn"
|
||||||
|
:class="{ active: isFavorite(item.key) }"
|
||||||
|
type="button"
|
||||||
|
:title="isFavorite(item.key) ? '取消收藏' : '收藏词条'"
|
||||||
|
@click.stop="toggleFavorite(item.key)"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" :fill="isFavorite(item.key) ? 'currentColor' : 'none'" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.55L12 17.52 6.12 20.61l1.12-6.55L2.48 9.42l6.58-.96L12 2.5z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div class="pqa-tags" ref="tagsContainer" @scroll="onScroll">
|
<div class="pqa-tags" ref="tagsContainer" @scroll="onScroll">
|
||||||
<button v-for="tag in visibleTags" :key="tag.key" class="pqa-tag" draggable="true" @click="onTagClick(tag)"
|
<div v-for="item in visibleItems" :key="item.key" class="pqa-tag-card">
|
||||||
@dragstart="onTagDragStart(tag, $event)" @dragend="onTagDragEnd"
|
<button class="pqa-tag" draggable="true" @click="onQuickAddItemClick(item)"
|
||||||
:title="tag.key">
|
@dragstart="onQuickAddItemDragStart(item, $event)" @dragend="onTagDragEnd"
|
||||||
<span class="pqa-tag-text">{{ displayTrans(tag) }}</span>
|
:title="item.key">
|
||||||
<span class="pqa-tag-sub" v-if="displayTrans(tag) !== tag.key">{{ tag.key }}</span>
|
<span class="pqa-tag-text">{{ item.translation }}</span>
|
||||||
</button>
|
<span class="pqa-tag-sub" v-if="item.translation !== item.key">{{ item.key }}</span>
|
||||||
<div v-if="filteredTags.length === 0" class="pqa-empty">
|
<span class="pqa-tag-path" v-if="localSearch && item.categoryName && item.groupName">
|
||||||
|
{{ item.categoryName }} / {{ item.groupName }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="pqa-fav-btn"
|
||||||
|
:class="{ active: isFavorite(item.key) }"
|
||||||
|
type="button"
|
||||||
|
:title="isFavorite(item.key) ? '取消收藏' : '收藏词条'"
|
||||||
|
@click.stop="toggleFavorite(item.key)"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" :fill="isFavorite(item.key) ? 'currentColor' : 'none'" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.55L12 17.52 6.12 20.61l1.12-6.55L2.48 9.42l6.58-.96L12 2.5z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="allVisibleItems.length === 0" class="pqa-empty">
|
||||||
无相关提示词
|
无相关提示词
|
||||||
</div>
|
</div>
|
||||||
<div v-if="visibleCount < filteredTags.length" class="pqa-loading-more">
|
<div v-if="visibleCount < allVisibleItems.length" class="pqa-loading-more">
|
||||||
...
|
...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,6 +480,74 @@ function displayTrans(tag: PromptTag) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pqa-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 2.25rem 0.6rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-search-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.5rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-search-clear:hover {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-summary {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-favorites,
|
||||||
|
.pqa-recent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-favorites-title,
|
||||||
|
.pqa-recent-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.pqa-categories {
|
.pqa-categories {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -230,6 +646,42 @@ function displayTrans(tag: PromptTag) {
|
|||||||
background-color: var(--group-color);
|
background-color: var(--group-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pqa-chip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-chip-card {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-chip {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-chip:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-chip-favorite {
|
||||||
|
border-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-accent-light) 45%, var(--color-bg-primary));
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.pqa-tags {
|
.pqa-tags {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -240,6 +692,13 @@ function displayTrans(tag: PromptTag) {
|
|||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pqa-tag-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.pqa-tag {
|
.pqa-tag {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -253,6 +712,7 @@ function displayTrans(tag: PromptTag) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.1s;
|
transition: all 0.1s;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pqa-tag:hover {
|
.pqa-tag:hover {
|
||||||
@@ -278,6 +738,41 @@ function displayTrans(tag: PromptTag) {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pqa-tag-path {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-fav-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.7rem;
|
||||||
|
height: 1.7rem;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-fav-btn:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pqa-fav-btn.active {
|
||||||
|
border-color: color-mix(in srgb, var(--color-accent) 45%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-accent-light) 55%, var(--color-bg-primary));
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.pqa-empty {
|
.pqa-empty {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick } from 'vue';
|
import { ref, computed, nextTick } from 'vue';
|
||||||
import { splitTokens, parseDetailedToken, constructToken } from '../../stores/promptStore';
|
import { splitTokens, parseDetailedToken, constructToken, normalizeSymbols } from '../../stores/promptStore';
|
||||||
import PromptQuickAdd from '../PromptQuickAdd.vue';
|
import PromptQuickAdd from '../PromptQuickAdd.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
text: string;
|
text: string;
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
priorityStyle: '{}' | '()' | '[]' | '<>' | 'suffix';
|
numericMode: boolean;
|
||||||
|
bracketStyle: '()' | '{}' | '[]' | '<>';
|
||||||
priorityStep: number;
|
priorityStep: number;
|
||||||
getSuggestions: (prefix: string, limit: number) => string[];
|
getSuggestions: (prefix: string, limit: number) => string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:text': [value: string];
|
'update:text': [value: string];
|
||||||
'update:priorityStyle': [value: '{}' | '()' | '[]' | '<>' | 'suffix'];
|
'update:numericMode': [value: boolean];
|
||||||
|
'update:bracketStyle': [value: '()' | '{}' | '[]' | '<>'];
|
||||||
'update:priorityStep': [value: number];
|
'update:priorityStep': [value: number];
|
||||||
'update-suggestions': [];
|
'update-suggestions': [];
|
||||||
'copy': [];
|
'copy': [];
|
||||||
@@ -24,6 +26,7 @@ const emit = defineEmits<{
|
|||||||
'add-tag': [tag: string];
|
'add-tag': [tag: string];
|
||||||
'drag-tag-start': [tag: string];
|
'drag-tag-start': [tag: string];
|
||||||
'drag-tag-end': [];
|
'drag-tag-end': [];
|
||||||
|
'locate-token': [index: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
||||||
@@ -33,9 +36,14 @@ const localText = computed({
|
|||||||
set: (v: string) => emit('update:text', v),
|
set: (v: string) => emit('update:text', v),
|
||||||
});
|
});
|
||||||
|
|
||||||
const localPriorityStyle = computed({
|
const localNumericMode = computed({
|
||||||
get: () => props.priorityStyle,
|
get: () => props.numericMode,
|
||||||
set: (v: '{}' | '()' | '[]' | '<>' | 'suffix') => emit('update:priorityStyle', v),
|
set: (v: boolean) => emit('update:numericMode', v),
|
||||||
|
});
|
||||||
|
|
||||||
|
const localBracketStyle = computed({
|
||||||
|
get: () => props.bracketStyle,
|
||||||
|
set: (v: '()' | '{}' | '[]' | '<>') => emit('update:bracketStyle', v),
|
||||||
});
|
});
|
||||||
|
|
||||||
const localPriorityStep = computed({
|
const localPriorityStep = computed({
|
||||||
@@ -147,9 +155,41 @@ async function applySuggestion(s: string) {
|
|||||||
emit('update-suggestions');
|
emit('update-suggestions');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSuggestions() {
|
// 计算与 splitTokens 对齐的非空 token 区间(用于光标定位)
|
||||||
// 通知父组件更新建议
|
function computeTokenRanges(txt: string): { start: number; end: number }[] {
|
||||||
|
const ranges: { start: number; end: number }[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let segStart = 0;
|
||||||
|
for (let i = 0; i < txt.length; i++) {
|
||||||
|
const c = txt[i]!;
|
||||||
|
if (c === '(' || c === '[' || c === '{' || c === '<') depth++;
|
||||||
|
else if (c === ')' || c === ']' || c === '}' || c === '>') depth = Math.max(0, depth - 1);
|
||||||
|
if ((c === ',' || c === '\n') && depth === 0) {
|
||||||
|
ranges.push({ start: segStart, end: i });
|
||||||
|
segStart = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges.push({ start: segStart, end: txt.length });
|
||||||
|
return ranges.filter(r => txt.slice(r.start, r.end).trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据光标位置定位对应的 token 序号,通知父组件高亮右侧映射
|
||||||
|
function emitLocate() {
|
||||||
|
const el = inputEl.value;
|
||||||
|
if (!el) return;
|
||||||
|
const norm = normalizeSymbols(props.text);
|
||||||
|
const pos = el.selectionStart ?? norm.length;
|
||||||
|
const ranges = computeTokenRanges(norm);
|
||||||
|
if (!ranges.length) { emit('locate-token', -1); return; }
|
||||||
|
let idx = ranges.findIndex(r => pos >= r.start && pos <= r.end);
|
||||||
|
if (idx === -1) idx = ranges.findIndex(r => pos <= r.end);
|
||||||
|
if (idx === -1) idx = ranges.length - 1;
|
||||||
|
emit('locate-token', idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCursorActivity() {
|
||||||
emit('update-suggestions');
|
emit('update-suggestions');
|
||||||
|
emitLocate();
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -165,8 +205,9 @@ defineExpose({
|
|||||||
class="pe-input"
|
class="pe-input"
|
||||||
v-model="localText"
|
v-model="localText"
|
||||||
@keydown="onKeyDown"
|
@keydown="onKeyDown"
|
||||||
@click="updateSuggestions"
|
@click="onCursorActivity"
|
||||||
@keyup="updateSuggestions"
|
@keyup="onCursorActivity"
|
||||||
|
@blur="emit('locate-token', -1)"
|
||||||
placeholder="例如:1girl, aaa, bbb, ccc"
|
placeholder="例如:1girl, aaa, bbb, ccc"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="pe-input-actions">
|
<div class="pe-input-actions">
|
||||||
@@ -204,24 +245,41 @@ defineExpose({
|
|||||||
切换 _/空格
|
切换 _/空格
|
||||||
</button>
|
</button>
|
||||||
<div class="pe-priority-group">
|
<div class="pe-priority-group">
|
||||||
<label class="pe-priority-label">优先级样式</label>
|
<button
|
||||||
<select class="pe-priority-select" v-model="localPriorityStyle" title="选择新增优先级的样式">
|
type="button"
|
||||||
<option value="{}">{}</option>
|
class="pe-mode-toggle"
|
||||||
<option value="()">()</option>
|
:class="{ 'is-numeric': localNumericMode }"
|
||||||
<option value="[]">[]</option>
|
role="switch"
|
||||||
<option value="<>"><></option>
|
:aria-checked="localNumericMode"
|
||||||
<option value="suffix">后缀数字</option>
|
@click="localNumericMode = !localNumericMode"
|
||||||
</select>
|
:title="localNumericMode ? '当前:数字权重模式(点击切换为括号嵌套)' : '当前:括号嵌套模式(点击切换为数字权重)'"
|
||||||
<label class="pe-priority-label">后缀数字间隔</label>
|
>
|
||||||
<input
|
<span class="pe-mode-opt" :class="{ active: !localNumericMode }">括号</span>
|
||||||
type="number"
|
<span class="pe-mode-opt" :class="{ active: localNumericMode }">数字</span>
|
||||||
class="pe-priority-step"
|
<span class="pe-mode-knob"></span>
|
||||||
v-model.number="localPriorityStep"
|
</button>
|
||||||
title="设置增减间隔"
|
|
||||||
min="0.01"
|
<template v-if="!localNumericMode">
|
||||||
step="0.01"
|
<label class="pe-priority-label">括号样式</label>
|
||||||
placeholder="1"
|
<select class="pe-priority-select" v-model="localBracketStyle" title="选择外套括号的样式">
|
||||||
/>
|
<option value="()">( )</option>
|
||||||
|
<option value="{}">{ }</option>
|
||||||
|
<option value="[]">[ ]</option>
|
||||||
|
<option value="<>">< ></option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="pe-priority-label">权重步进</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="pe-priority-step"
|
||||||
|
v-model.number="localPriorityStep"
|
||||||
|
title="设置每次加减的权重步进"
|
||||||
|
min="0.01"
|
||||||
|
step="0.05"
|
||||||
|
placeholder="0.1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="pe-suggest" v-if="suggestions.length">
|
<ul class="pe-suggest" v-if="suggestions.length">
|
||||||
@@ -322,6 +380,59 @@ defineExpose({
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 模式切换开关:括号 / 数字 */
|
||||||
|
.pe-mode-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-mode-toggle:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-mode-opt {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-mode-opt.active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-mode-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: calc(50% - 4px);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.25s cubic-bezier(0.34, 1.4, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-mode-toggle.is-numeric .pe-mode-knob {
|
||||||
|
transform: translateX(calc(100% + 4px));
|
||||||
|
}
|
||||||
|
|
||||||
.pe-priority-label {
|
.pe-priority-label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { computed } from 'vue';
|
|||||||
import type { LangCode, PresetFolder } from '../../types';
|
import type { LangCode, PresetFolder } from '../../types';
|
||||||
import PresetDropdown from '../PresetDropdown.vue';
|
import PresetDropdown from '../PresetDropdown.vue';
|
||||||
import FolderSelector from '../preset/FolderSelector.vue';
|
import FolderSelector from '../preset/FolderSelector.vue';
|
||||||
|
import IconHeart from '../icons/IconHeart.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
languages: LangCode[];
|
languages: LangCode[];
|
||||||
selectedLang: LangCode;
|
selectedLang: LangCode;
|
||||||
presetName: string;
|
presetName: string;
|
||||||
selectedFolderId: string;
|
selectedFolderId: string;
|
||||||
|
saveAsFavorite: boolean;
|
||||||
folderTree: any[];
|
folderTree: any[];
|
||||||
flattenedFolders: any[];
|
flattenedFolders: any[];
|
||||||
showPresetDropdown: boolean;
|
showPresetDropdown: boolean;
|
||||||
@@ -18,6 +20,7 @@ const emit = defineEmits<{
|
|||||||
'update:selectedLang': [value: LangCode];
|
'update:selectedLang': [value: LangCode];
|
||||||
'update:presetName': [value: string];
|
'update:presetName': [value: string];
|
||||||
'update:selectedFolderId': [value: string];
|
'update:selectedFolderId': [value: string];
|
||||||
|
'update:saveAsFavorite': [value: boolean];
|
||||||
'update:showPresetDropdown': [value: boolean];
|
'update:showPresetDropdown': [value: boolean];
|
||||||
'copy': [];
|
'copy': [];
|
||||||
'save-preset': [];
|
'save-preset': [];
|
||||||
@@ -42,6 +45,11 @@ const localSelectedFolderId = computed({
|
|||||||
set: (v: string) => emit('update:selectedFolderId', v),
|
set: (v: string) => emit('update:selectedFolderId', v),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const localSaveAsFavorite = computed({
|
||||||
|
get: () => props.saveAsFavorite,
|
||||||
|
set: (v: boolean) => emit('update:saveAsFavorite', v),
|
||||||
|
});
|
||||||
|
|
||||||
const localShowPresetDropdown = computed({
|
const localShowPresetDropdown = computed({
|
||||||
get: () => props.showPresetDropdown,
|
get: () => props.showPresetDropdown,
|
||||||
set: (v: boolean) => emit('update:showPresetDropdown', v),
|
set: (v: boolean) => emit('update:showPresetDropdown', v),
|
||||||
@@ -74,6 +82,16 @@ const localShowPresetDropdown = computed({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input class="pe-preset-name" placeholder="保存为预设名称" v-model="localPresetName" />
|
<input class="pe-preset-name" placeholder="保存为预设名称" v-model="localPresetName" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pe-fav-toggle"
|
||||||
|
:class="{ active: localSaveAsFavorite }"
|
||||||
|
@click="localSaveAsFavorite = !localSaveAsFavorite"
|
||||||
|
:title="localSaveAsFavorite ? '保存时收藏:已开启' : '保存时收藏:已关闭'"
|
||||||
|
:aria-pressed="localSaveAsFavorite"
|
||||||
|
>
|
||||||
|
<IconHeart :active="localSaveAsFavorite" width="16" height="16" />
|
||||||
|
</button>
|
||||||
<button @click="emit('save-preset')" title="保存当前提示词为预设">
|
<button @click="emit('save-preset')" title="保存当前提示词为预设">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<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"/>
|
<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"/>
|
||||||
@@ -203,6 +221,34 @@ const localShowPresetDropdown = computed({
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pe-fav-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-fav-toggle:hover {
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
color: #ec4899;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-fav-toggle.active {
|
||||||
|
color: #ec4899;
|
||||||
|
border-color: #f9a8d4;
|
||||||
|
background-color: rgba(236, 72, 153, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.pe-folder-select-wrapper {
|
.pe-folder-select-wrapper {
|
||||||
width: 130px;
|
width: 130px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick } from 'vue';
|
import { ref, computed, nextTick, watch } from 'vue';
|
||||||
import { parseDetailedToken, constructToken } from '../../stores/promptStore';
|
import { parseDetailedToken, constructToken } from '../../stores/promptStore';
|
||||||
import type { LangCode } from '../../types';
|
import type { LangCode } from '../../types';
|
||||||
|
|
||||||
@@ -12,7 +12,8 @@ const props = defineProps<{
|
|||||||
insertSide: 'before' | 'after' | null;
|
insertSide: 'before' | 'after' | null;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
editSuggestions: string[];
|
editSuggestions: string[];
|
||||||
priorityStyle: '{}' | '()' | '[]' | '<>' | 'suffix';
|
numericMode: boolean;
|
||||||
|
activeIndex: number | null;
|
||||||
displayTrans: (key: string) => string;
|
displayTrans: (key: string) => string;
|
||||||
isUnmapped: (key: string) => boolean;
|
isUnmapped: (key: string) => boolean;
|
||||||
getTokenWrapperInfo: (token: string) => { wrapperCount: number };
|
getTokenWrapperInfo: (token: string) => { wrapperCount: number };
|
||||||
@@ -54,6 +55,31 @@ const localViewMode = computed({
|
|||||||
set: (v: 'compact' | 'detail') => emit('update:viewMode', v),
|
set: (v: 'compact' | 'detail') => emit('update:viewMode', v),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 每个词条的展示数据只计算一次,避免模板中重复调用 displayTrans / isUnmapped
|
||||||
|
const rows = computed(() => props.tokens.map((key) => ({
|
||||||
|
key,
|
||||||
|
trans: props.displayTrans(key),
|
||||||
|
unmapped: props.isUnmapped(key),
|
||||||
|
removeDisabled: isRemoveDisabled(key),
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 左侧点击提示词 → 右侧映射定位并平滑滚动到视图中央(仅在出现滚动条时滚动)
|
||||||
|
watch(() => props.activeIndex, (idx) => {
|
||||||
|
if (idx == null || idx < 0) return;
|
||||||
|
nextTick(() => {
|
||||||
|
const container = dragContainer.value;
|
||||||
|
if (!container) return;
|
||||||
|
const el = container.querySelector<HTMLElement>(`[data-index="${idx}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
const scroller = container.closest('.pe-right-pane') as HTMLElement | null;
|
||||||
|
if (!scroller || scroller.scrollHeight <= scroller.clientHeight) return;
|
||||||
|
const erect = el.getBoundingClientRect();
|
||||||
|
const srect = scroller.getBoundingClientRect();
|
||||||
|
const delta = (erect.top - srect.top) - (srect.height / 2 - erect.height / 2);
|
||||||
|
scroller.scrollBy({ top: delta, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function beginEdit(i: number) {
|
function beginEdit(i: number) {
|
||||||
editingIndex.value = i;
|
editingIndex.value = i;
|
||||||
editingValue.value = props.tokens[i] ?? '';
|
editingValue.value = props.tokens[i] ?? '';
|
||||||
@@ -160,6 +186,7 @@ function updateEditSuggestions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRemoveDisabled(token: string): boolean {
|
function isRemoveDisabled(token: string): boolean {
|
||||||
|
if (props.numericMode) return false;
|
||||||
const info = props.getTokenWrapperInfo(token);
|
const info = props.getTokenWrapperInfo(token);
|
||||||
return info.wrapperCount === 0 && !props.hasWeightSuffix(token);
|
return info.wrapperCount === 0 && !props.hasWeightSuffix(token);
|
||||||
}
|
}
|
||||||
@@ -203,33 +230,49 @@ defineExpose({
|
|||||||
<!-- 精简视图 -->
|
<!-- 精简视图 -->
|
||||||
<div class="pe-tokens-compact" v-if="viewMode === 'compact'">
|
<div class="pe-tokens-compact" v-if="viewMode === 'compact'">
|
||||||
<div
|
<div
|
||||||
v-for="(k,i) in tokens"
|
v-for="(row,i) in rows"
|
||||||
:key="k + '_' + i"
|
:key="row.key + '_' + i"
|
||||||
:data-index="i"
|
:data-index="i"
|
||||||
:class="{
|
:class="{
|
||||||
'dragging': draggingIndex === i,
|
'dragging': draggingIndex === i,
|
||||||
'insert-before': overIndex === i && insertSide === 'before' && draggingIndex !== i,
|
'insert-before': overIndex === i && insertSide === 'before' && draggingIndex !== i,
|
||||||
'insert-after': overIndex === i && insertSide === 'after' && draggingIndex !== i,
|
'insert-after': overIndex === i && insertSide === 'after' && draggingIndex !== i,
|
||||||
'editing': editingIndex === i
|
'editing': editingIndex === i,
|
||||||
|
'is-active': activeIndex === i && draggingIndex !== i
|
||||||
}"
|
}"
|
||||||
class="pe-token-compact"
|
class="pe-token-compact"
|
||||||
@pointerdown="emit('pointer-down', i, $event)"
|
@pointerdown="emit('pointer-down', i, $event)"
|
||||||
@dblclick="beginEdit(i)"
|
@dblclick="beginEdit(i)"
|
||||||
:title="`${k} → ${displayTrans(k)}`"
|
:title="`${row.key} → ${row.trans}`"
|
||||||
>
|
>
|
||||||
<span class="pe-handle-compact">⋮⋮</span>
|
<span class="pe-handle-compact">⋮⋮</span>
|
||||||
<div v-if="editingIndex === i" class="pe-edit-inline">
|
<div v-if="editingIndex === i" class="pe-edit-inline">
|
||||||
<input
|
<div class="pe-edit-row">
|
||||||
ref="editEl"
|
<input
|
||||||
class="pe-edit-input"
|
ref="editEl"
|
||||||
v-model="editingValue"
|
class="pe-edit-input"
|
||||||
@keydown="onEditKeyDown"
|
v-model="editingValue"
|
||||||
@keydown.enter.stop.prevent="commitEdit"
|
@keydown="onEditKeyDown"
|
||||||
@keydown.esc.stop.prevent="cancelEdit"
|
@keydown.enter.stop.prevent="commitEdit"
|
||||||
@click="updateEditSuggestions"
|
@keydown.esc.stop.prevent="cancelEdit"
|
||||||
@keyup="updateEditSuggestions"
|
@click="updateEditSuggestions"
|
||||||
placeholder="编辑提示词"
|
@keyup="updateEditSuggestions"
|
||||||
/>
|
placeholder="编辑提示词"
|
||||||
|
/>
|
||||||
|
<div class="pe-edit-actions">
|
||||||
|
<button @click="commitEdit" class="pe-edit-save-btn" title="保存">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<polyline points="20 6 9 17 4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="cancelEdit" class="pe-edit-cancel-btn" title="取消">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ul class="pe-edit-suggest" v-if="editSuggestions.length">
|
<ul class="pe-edit-suggest" v-if="editSuggestions.length">
|
||||||
<li
|
<li
|
||||||
v-for="s in editSuggestions"
|
v-for="s in editSuggestions"
|
||||||
@@ -238,29 +281,16 @@ defineExpose({
|
|||||||
@click="applyEditSuggestion(s)"
|
@click="applyEditSuggestion(s)"
|
||||||
>{{ s }}</li>
|
>{{ s }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="pe-edit-actions">
|
|
||||||
<button @click="commitEdit" class="pe-edit-save-btn" title="保存">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<polyline points="20 6 9 17 4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button @click="cancelEdit" class="pe-edit-cancel-btn" title="取消">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="pe-token-content">
|
<div v-else class="pe-token-content">
|
||||||
<span class="pe-key-compact">{{ k }}</span>
|
<span class="pe-key-compact">{{ row.key }}</span>
|
||||||
<span class="pe-arrow-compact">→</span>
|
<span class="pe-arrow-compact">→</span>
|
||||||
<span class="pe-trans-compact" :class="{ unmapped: isUnmapped(k) }">
|
<span class="pe-trans-compact" :class="{ unmapped: row.unmapped }">
|
||||||
{{ displayTrans(k) }}
|
{{ row.trans }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pe-token-controls-compact">
|
<div class="pe-token-controls-compact">
|
||||||
<button @click="emit('add-wrapper', i)" class="pe-add-wrapper-btn" :title="`添加优先级(样式:${priorityStyle})`">
|
<button @click="emit('add-wrapper', i)" class="pe-add-wrapper-btn" :title="numericMode ? '提升权重' : '添加优先级(外套括号)'">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
|
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
|
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
|
||||||
@@ -270,8 +300,8 @@ defineExpose({
|
|||||||
<button
|
<button
|
||||||
@click="emit('remove-wrapper', i)"
|
@click="emit('remove-wrapper', i)"
|
||||||
class="pe-remove-wrapper-btn"
|
class="pe-remove-wrapper-btn"
|
||||||
title="移除优先级"
|
:title="numericMode ? '降低权重' : '移除优先级'"
|
||||||
:disabled="isRemoveDisabled(k)"
|
:disabled="row.removeDisabled"
|
||||||
>
|
>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
|
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
@@ -291,14 +321,15 @@ defineExpose({
|
|||||||
<!-- 详细视图 -->
|
<!-- 详细视图 -->
|
||||||
<div class="pe-tokens-detail" v-else>
|
<div class="pe-tokens-detail" v-else>
|
||||||
<div
|
<div
|
||||||
v-for="(k,i) in tokens"
|
v-for="(row,i) in rows"
|
||||||
:key="k + '_' + i"
|
:key="row.key + '_' + i"
|
||||||
:data-index="i"
|
:data-index="i"
|
||||||
:class="{
|
:class="{
|
||||||
'dragging': draggingIndex === i,
|
'dragging': draggingIndex === i,
|
||||||
'insert-before': overIndex === i && insertSide === 'before' && draggingIndex !== i,
|
'insert-before': overIndex === i && insertSide === 'before' && draggingIndex !== i,
|
||||||
'insert-after': overIndex === i && insertSide === 'after' && draggingIndex !== i,
|
'insert-after': overIndex === i && insertSide === 'after' && draggingIndex !== i,
|
||||||
'editing': editingIndex === i
|
'editing': editingIndex === i,
|
||||||
|
'is-active': activeIndex === i && draggingIndex !== i
|
||||||
}"
|
}"
|
||||||
class="pe-token-detail"
|
class="pe-token-detail"
|
||||||
@pointerdown="emit('pointer-down', i, $event)"
|
@pointerdown="emit('pointer-down', i, $event)"
|
||||||
@@ -306,18 +337,18 @@ defineExpose({
|
|||||||
<div class="pe-token-header">
|
<div class="pe-token-header">
|
||||||
<span class="pe-handle-detail">⋮⋮</span>
|
<span class="pe-handle-detail">⋮⋮</span>
|
||||||
<div class="pe-token-main" @dblclick="beginEdit(i)">
|
<div class="pe-token-main" @dblclick="beginEdit(i)">
|
||||||
<span class="pe-key-detail">{{ k }}</span>
|
<span class="pe-key-detail">{{ row.key }}</span>
|
||||||
<span class="pe-arrow-detail">→</span>
|
<span class="pe-arrow-detail">→</span>
|
||||||
<span class="pe-trans-detail" :class="{ unmapped: isUnmapped(k) }">{{ displayTrans(k) }}</span>
|
<span class="pe-trans-detail" :class="{ unmapped: row.unmapped }">{{ row.trans }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pe-token-controls">
|
<div class="pe-token-controls">
|
||||||
<button v-if="isUnmapped(k)" class="pe-add-map-btn" @click="emit('show-add-map', i)" title="添加映射">
|
<button v-if="row.unmapped" class="pe-add-map-btn" @click="emit('show-add-map', i)" title="添加映射">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2"/>
|
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2"/>
|
||||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2"/>
|
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="emit('add-wrapper', i)" class="pe-add-wrapper-detail-btn" :title="`添加优先级(样式:${priorityStyle})`">
|
<button @click="emit('add-wrapper', i)" class="pe-add-wrapper-detail-btn" :title="numericMode ? '提升权重' : '添加优先级(外套括号)'">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
|
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
|
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
|
||||||
@@ -327,8 +358,8 @@ defineExpose({
|
|||||||
<button
|
<button
|
||||||
@click="emit('remove-wrapper', i)"
|
@click="emit('remove-wrapper', i)"
|
||||||
class="pe-remove-wrapper-detail-btn"
|
class="pe-remove-wrapper-detail-btn"
|
||||||
title="移除优先级"
|
:title="numericMode ? '降低权重' : '移除优先级'"
|
||||||
:disabled="isRemoveDisabled(k)"
|
:disabled="row.removeDisabled"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
|
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
@@ -488,6 +519,10 @@ defineExpose({
|
|||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
|
/* 编辑时整行展开,给输入框与候选词留足空间,避免被压缩 */
|
||||||
|
width: 100%;
|
||||||
|
flex-basis: 100%;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pe-token-compact.editing .pe-handle-compact,
|
.pe-token-compact.editing .pe-handle-compact,
|
||||||
@@ -498,10 +533,19 @@ defineExpose({
|
|||||||
|
|
||||||
.pe-edit-inline {
|
.pe-edit-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-edit-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pe-edit-input {
|
.pe-edit-input {
|
||||||
@@ -917,6 +961,8 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
max-height: 7.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pe-edit-suggest li {
|
.pe-edit-suggest li {
|
||||||
@@ -990,6 +1036,34 @@ defineExpose({
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 左侧点击定位时的高亮(提升)效果 */
|
||||||
|
.pe-token-compact.is-active,
|
||||||
|
.pe-token-detail.is-active {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-light), var(--shadow-md);
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-token-compact.is-active {
|
||||||
|
transform: translateY(-1px) scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-token-detail.is-active {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pe-token-compact.is-active:not(.editing),
|
||||||
|
.pe-token-detail.is-active:not(.editing) {
|
||||||
|
animation: locatePulse 0.7s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes locatePulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 var(--color-accent); }
|
||||||
|
60% { box-shadow: 0 0 0 6px transparent; }
|
||||||
|
100% { box-shadow: 0 0 0 2px var(--color-accent-light), var(--shadow-md); }
|
||||||
|
}
|
||||||
|
|
||||||
.pe-token-compact,
|
.pe-token-compact,
|
||||||
.pe-token-detail {
|
.pe-token-detail {
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.25"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-check"
|
||||||
|
>
|
||||||
|
<circle class="check-ring" cx="12" cy="12" r="9" />
|
||||||
|
<path class="check-mark" d="M7 12.5 10.2 15.7 17 8.9" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-check {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-ring,
|
||||||
|
.check-mark {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease, stroke-dashoffset 0.35s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-mark {
|
||||||
|
stroke-dasharray: 18;
|
||||||
|
stroke-dashoffset: 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.action-btn:hover) .check-ring,
|
||||||
|
:deep(.action-btn:hover) .check-mark,
|
||||||
|
.icon-check:hover .check-ring,
|
||||||
|
.icon-check:hover .check-mark {
|
||||||
|
transform: scale(1.06);
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-copy"
|
||||||
|
>
|
||||||
|
<rect class="copy-back" x="7" y="7" width="11" height="11" rx="2" />
|
||||||
|
<rect class="copy-front" x="4" y="4" width="11" height="11" rx="2" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-copy {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-back,
|
||||||
|
.copy-front {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.action-btn:hover) .copy-back,
|
||||||
|
.icon-copy:hover .copy-back {
|
||||||
|
transform: translate(1px, 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.action-btn:hover) .copy-front,
|
||||||
|
.icon-copy:hover .copy-front {
|
||||||
|
transform: translate(-1px, -1px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 48"
|
||||||
|
:height="height || 48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-empty-state"
|
||||||
|
>
|
||||||
|
<path class="empty-box" d="M4 7h16v10H4z" />
|
||||||
|
<path class="empty-line" d="M7 11h10" />
|
||||||
|
<path class="empty-line" d="M7 14h6" />
|
||||||
|
<circle class="empty-spark spark-1" cx="17" cy="8" r="1" fill="currentColor" />
|
||||||
|
<circle class="empty-spark spark-2" cx="8" cy="6" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-empty-state {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-box,
|
||||||
|
.empty-line,
|
||||||
|
.empty-spark {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-spark {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-empty-state:hover .empty-box {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-empty-state:hover .spark-1 {
|
||||||
|
transform: translateY(-1px) scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-empty-state:hover .spark-2 {
|
||||||
|
transform: translateY(1px) scale(1.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-export"
|
||||||
|
>
|
||||||
|
<path class="tray" d="M4 15v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4" />
|
||||||
|
<path class="arrow" d="M12 3v12" />
|
||||||
|
<path class="arrow-head" d="M7 8l5-5 5 5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-export {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray,
|
||||||
|
.arrow,
|
||||||
|
.arrow-head {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-icon:hover) .arrow,
|
||||||
|
.icon-export:hover .arrow {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-icon:hover) .arrow-head,
|
||||||
|
.icon-export:hover .arrow-head {
|
||||||
|
transform: translateY(-1px) scale(1.06);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
active?: boolean
|
||||||
|
}>(), {
|
||||||
|
active: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-heart"
|
||||||
|
:class="{ 'is-active': active }"
|
||||||
|
>
|
||||||
|
<path class="heart-glow" d="M12 20.5 10.9 19.5C6.1 15.4 3.4 12.9 3.4 9.6 3.4 7.1 5.4 5 7.9 5c1.5 0 2.8.7 3.6 1.9.8-1.2 2.1-1.9 3.6-1.9 2.5 0 4.5 2.1 4.5 4.6 0 3.3-2.7 5.8-7.5 9.9L12 20.5Z" />
|
||||||
|
<path class="heart-fill" fill="currentColor" d="M12 20.5 10.9 19.5C6.1 15.4 3.4 12.9 3.4 9.6 3.4 7.1 5.4 5 7.9 5c1.5 0 2.8.7 3.6 1.9.8-1.2 2.1-1.9 3.6-1.9 2.5 0 4.5 2.1 4.5 4.6 0 3.3-2.7 5.8-7.5 9.9L12 20.5Z" />
|
||||||
|
<path class="heart-outline" d="M12 20.5 10.9 19.5C6.1 15.4 3.4 12.9 3.4 9.6 3.4 7.1 5.4 5 7.9 5c1.5 0 2.8.7 3.6 1.9.8-1.2 2.1-1.9 3.6-1.9 2.5 0 4.5 2.1 4.5 4.6 0 3.3-2.7 5.8-7.5 9.9L12 20.5Z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-heart {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-glow,
|
||||||
|
.heart-fill,
|
||||||
|
.heart-outline {
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 0.38s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease, fill 0.25s ease, stroke 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-glow {
|
||||||
|
fill: currentColor;
|
||||||
|
opacity: 0;
|
||||||
|
stroke: none;
|
||||||
|
filter: blur(1.25px);
|
||||||
|
transform: scale(0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-fill {
|
||||||
|
fill: currentColor;
|
||||||
|
opacity: 0;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-heart.is-active .heart-fill {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-heart.is-active .heart-outline {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-heart:hover,
|
||||||
|
:deep(.action-btn:hover) .icon-heart,
|
||||||
|
:deep(.action-btn.is-favorite:hover) .icon-heart {
|
||||||
|
transform: translateY(-1px) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-heart:hover .heart-outline,
|
||||||
|
:deep(.action-btn:hover) .heart-outline {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-heart:hover .heart-glow,
|
||||||
|
:deep(.action-btn:hover) .heart-glow {
|
||||||
|
opacity: 0.2;
|
||||||
|
transform: scale(1.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-heart.is-active:hover .heart-fill,
|
||||||
|
:deep(.action-btn.is-favorite:hover) .heart-fill {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-heart.is-active:hover .heart-glow,
|
||||||
|
:deep(.action-btn.is-favorite:hover) .heart-glow {
|
||||||
|
opacity: 0.24;
|
||||||
|
transform: scale(1.22);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-import"
|
||||||
|
>
|
||||||
|
<path class="tray" d="M4 15v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4" />
|
||||||
|
<path class="arrow" d="M12 3v12" />
|
||||||
|
<path class="arrow-head" d="m7 10 5 5 5-5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-import {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray,
|
||||||
|
.arrow,
|
||||||
|
.arrow-head {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-icon:hover) .arrow,
|
||||||
|
.icon-import:hover .arrow {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-icon:hover) .arrow-head,
|
||||||
|
.icon-import:hover .arrow-head {
|
||||||
|
transform: translateY(1px) scale(1.06);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-menu"
|
||||||
|
>
|
||||||
|
<path class="menu-line line-1" d="M4 7h16" />
|
||||||
|
<path class="menu-line line-2" d="M4 12h16" />
|
||||||
|
<path class="menu-line line-3" d="M4 17h16" />
|
||||||
|
<circle class="menu-spark" cx="19" cy="7" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-menu {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-line,
|
||||||
|
.menu-spark {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.mobile-menu-btn:hover) .line-1,
|
||||||
|
.icon-menu:hover .line-1 {
|
||||||
|
transform: translateY(-1px) rotate(4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.mobile-menu-btn:hover) .line-2,
|
||||||
|
.icon-menu:hover .line-2 {
|
||||||
|
transform: scaleX(0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.mobile-menu-btn:hover) .line-3,
|
||||||
|
.icon-menu:hover .line-3 {
|
||||||
|
transform: translateY(1px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.mobile-menu-btn:hover) .menu-spark,
|
||||||
|
.icon-menu:hover .menu-spark {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-more"
|
||||||
|
>
|
||||||
|
<circle class="more-dot dot-1" cx="5" cy="12" r="1.2" fill="currentColor" />
|
||||||
|
<circle class="more-dot dot-2" cx="12" cy="12" r="1.2" fill="currentColor" />
|
||||||
|
<circle class="more-dot dot-3" cx="19" cy="12" r="1.2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-more {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-dot {
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.more-btn:hover) .dot-1,
|
||||||
|
.icon-more:hover .dot-1 {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.more-btn:hover) .dot-2,
|
||||||
|
.icon-more:hover .dot-2 {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.more-btn:hover) .dot-3,
|
||||||
|
.icon-more:hover .dot-3 {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.25"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-plus"
|
||||||
|
>
|
||||||
|
<circle class="plus-ring" cx="12" cy="12" r="9" />
|
||||||
|
<path class="plus-vert" d="M12 7v10" />
|
||||||
|
<path class="plus-horz" d="M7 12h10" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-plus {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-ring,
|
||||||
|
.plus-vert,
|
||||||
|
.plus-horz {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease, stroke-dashoffset 0.35s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-primary:hover) .plus-ring,
|
||||||
|
.icon-plus:hover .plus-ring {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-primary:hover) .plus-vert,
|
||||||
|
.icon-plus:hover .plus-vert {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-primary:hover) .plus-horz,
|
||||||
|
.icon-plus:hover .plus-horz {
|
||||||
|
transform: scaleX(1.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,50 +4,50 @@
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
class="icon-preset-type"
|
class="icon-preset-type"
|
||||||
:class="type"
|
:class="type"
|
||||||
>
|
>
|
||||||
<!-- Positive: Sparkles/Magic -->
|
|
||||||
<template v-if="type === 'positive'">
|
<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" />
|
<path class="positive-wand wand-shadow" d="M7.1 17.2 16.8 7.5" />
|
||||||
|
<path class="positive-wand wand-body" d="M6.4 16.5 16.1 6.8" />
|
||||||
|
<path class="positive-cap" d="M16.1 6.8 18.2 4.7" />
|
||||||
|
<path class="positive-tip tip-1" d="M17.7 3.1 18.3 4.5 19.7 5.1 18.3 5.7 17.7 7.1 17.1 5.7 15.7 5.1 17.1 4.5Z" />
|
||||||
|
<path class="positive-tip tip-2" d="M9.2 6.3 9.6 7.3 10.6 7.7 9.6 8.1 9.2 9.1 8.8 8.1 7.8 7.7 8.8 7.3Z" />
|
||||||
|
<circle class="positive-tip dot-1" cx="14.1" cy="10.9" r="0.7" />
|
||||||
|
<circle class="positive-tip dot-2" cx="6.9" cy="18.1" r="0.8" />
|
||||||
|
<path class="positive-spark spark-1" d="M19.6 10.6 20 11.7 21.1 12.1 20 12.5 19.6 13.6 19.2 12.5 18.1 12.1 19.2 11.7Z" />
|
||||||
|
<path class="positive-spark spark-2" d="M4.8 10.8 5.2 11.6 6 12 5.2 12.4 4.8 13.2 4.4 12.4 3.6 12 4.4 11.6Z" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Negative: Ban/Stop -->
|
|
||||||
<template v-else-if="type === 'negative'">
|
<template v-else-if="type === 'negative'">
|
||||||
<circle class="circle" cx="12" cy="12" r="10" />
|
<circle class="negative-ring" cx="12" cy="12" r="9.2" />
|
||||||
<line class="slash" x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
<line class="negative-slash" x1="5" y1="5" x2="19" y2="19" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Setting: Gear -->
|
|
||||||
<template v-else-if="type === 'setting'">
|
<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"/>
|
<path class="setting-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>
|
</template>
|
||||||
|
|
||||||
<!-- Style: Brush -->
|
|
||||||
<template v-else-if="type === 'style'">
|
<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="style-brush" 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"/>
|
<path class="style-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>
|
</template>
|
||||||
|
|
||||||
<!-- Character: User/Face -->
|
|
||||||
<template v-else-if="type === 'character'">
|
<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" />
|
<path class="character-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" />
|
<circle class="character-head" cx="12" cy="7" r="4" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Scene: Image/Landscape -->
|
|
||||||
<template v-else-if="type === 'scene'">
|
<template v-else-if="type === 'scene'">
|
||||||
<rect class="scene-frame" x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
<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"/>
|
<circle class="scene-sun" cx="8.5" cy="8.5" r="1.5" />
|
||||||
<polyline class="scene-mountain" points="21 15 16 10 5 21"/>
|
<polyline class="scene-mountain" points="21 15 16 10 5 21" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Custom: Puzzle -->
|
|
||||||
<template v-else>
|
<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"/>
|
<path class="custom-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>
|
</template>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
@@ -62,93 +62,158 @@ defineProps<{
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.icon-preset-type {
|
.icon-preset-type {
|
||||||
transition: all 0.3s ease;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Positive: Sparkle/Twinkle */
|
.positive-wand,
|
||||||
.positive .star-main {
|
.positive-cap,
|
||||||
|
.positive-tip,
|
||||||
|
.positive-spark,
|
||||||
|
.negative-ring,
|
||||||
|
.negative-slash,
|
||||||
|
.setting-gear,
|
||||||
|
.style-brush,
|
||||||
|
.style-tip,
|
||||||
|
.character-body,
|
||||||
|
.character-head,
|
||||||
|
.scene-frame,
|
||||||
|
.scene-sun,
|
||||||
|
.scene-mountain,
|
||||||
|
.custom-piece {
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease, stroke-dashoffset 0.35s ease, fill 0.25s ease;
|
||||||
}
|
}
|
||||||
:deep(.nav-btn:hover) .positive .star-main,
|
|
||||||
.positive:hover .star-main {
|
.positive-wand {
|
||||||
transform: scale(1.1) rotate(15deg);
|
stroke-width: 3.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive-wand.wand-shadow {
|
||||||
|
opacity: 0.18;
|
||||||
|
filter: blur(0.2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive-wand.wand-body {
|
||||||
|
stroke-width: 2.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive-cap {
|
||||||
|
stroke-width: 2.7;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive-tip {
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
fill-opacity: 0.2;
|
stroke: none;
|
||||||
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Negative: Shake */
|
.positive-spark {
|
||||||
.negative .slash {
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative-ring {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative-slash {
|
||||||
transform-origin: center;
|
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 {
|
||||||
.setting .gear {
|
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
transition: transform 0.5s ease;
|
|
||||||
}
|
|
||||||
:deep(.nav-btn:hover) .setting .gear,
|
|
||||||
.setting:hover .gear {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style: Brush Wiggle */
|
.style-tip {
|
||||||
.style .brush-tip {
|
transform-origin: 10px 13px;
|
||||||
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-head {
|
||||||
.character .user-head {
|
|
||||||
transform-origin: center;
|
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-frame {
|
||||||
.scene .scene-mountain {
|
opacity: 0.95;
|
||||||
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 */
|
.scene-sun {
|
||||||
.custom .puzzle-piece {
|
fill: currentColor;
|
||||||
transform-origin: center;
|
opacity: 0.9;
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
}
|
||||||
:deep(.nav-btn:hover) .custom .puzzle-piece,
|
|
||||||
.custom:hover .puzzle-piece {
|
.icon-preset-type:hover,
|
||||||
transform: scale(1.1) rotate(-5deg);
|
:deep(.nav-btn:hover) .icon-preset-type {
|
||||||
|
transform: translateY(-1px) scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.positive:hover .positive-wand,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.positive .positive-wand {
|
||||||
|
transform: translate(-0.2px, -0.2px) rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.positive:hover .wand-shadow,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.positive .wand-shadow {
|
||||||
|
opacity: 0.26;
|
||||||
|
transform: translate(0.6px, 0.6px) rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.positive:hover .positive-tip,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.positive .positive-tip {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-0.5px) scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.positive:hover .positive-spark,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.positive .positive-spark {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.positive:hover .spark-1,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.positive .spark-1 {
|
||||||
|
transform: translate(0.6px, -0.4px) scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.positive:hover .spark-2,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.positive .spark-2 {
|
||||||
|
transform: translate(-0.4px, 0.4px) scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.negative:hover .negative-slash,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.negative .negative-slash {
|
||||||
|
transform: rotate(3deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.setting:hover .setting-gear,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.setting .setting-gear {
|
||||||
|
transform: rotate(70deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.style:hover .style-tip,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.style .style-tip {
|
||||||
|
transform: translate(1px, -1px) rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.character:hover .character-head,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.character .character-head {
|
||||||
|
transform: translateY(-1.5px) scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.scene:hover .scene-mountain,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.scene .scene-mountain {
|
||||||
|
transform: translateY(-0.5px) scaleY(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.scene:hover .scene-sun,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.scene .scene-sun {
|
||||||
|
transform: translateY(-1px) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preset-type.custom:hover .custom-piece,
|
||||||
|
:deep(.nav-btn:hover) .icon-preset-type.custom .custom-piece {
|
||||||
|
transform: rotate(-5deg) scale(1.06);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-search"
|
||||||
|
>
|
||||||
|
<circle class="search-lens" cx="11" cy="11" r="7" />
|
||||||
|
<path class="search-handle" d="m16 16 4 4" />
|
||||||
|
<circle class="search-glint" cx="9" cy="9" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-search {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-lens,
|
||||||
|
.search-handle,
|
||||||
|
.search-glint {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease, stroke-dashoffset 0.35s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.search-box:hover) .search-lens,
|
||||||
|
.icon-search:hover .search-lens {
|
||||||
|
transform: scale(1.06);
|
||||||
|
stroke-dasharray: 40 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.search-box:hover) .search-handle,
|
||||||
|
.icon-search:hover .search-handle {
|
||||||
|
transform: translate(1px, 1px) rotate(12deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.search-box:hover) .search-glint,
|
||||||
|
.icon-search:hover .search-glint {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-share"
|
||||||
|
>
|
||||||
|
<circle class="share-node node-1" cx="18" cy="5" r="3" />
|
||||||
|
<circle class="share-node node-2" cx="6" cy="12" r="3" />
|
||||||
|
<circle class="share-node node-3" cx="18" cy="19" r="3" />
|
||||||
|
<path class="share-link link-1" d="M8.59 13.51 15.42 17.49" />
|
||||||
|
<path class="share-link link-2" d="M15.41 6.51 8.59 10.49" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-share {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-node,
|
||||||
|
.share-link {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-icon:hover) .share-node,
|
||||||
|
:deep(.dropdown-content button:hover) .share-node,
|
||||||
|
.icon-share:hover .share-node {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-icon:hover) .link-1,
|
||||||
|
:deep(.dropdown-content button:hover) .link-1,
|
||||||
|
.icon-share:hover .link-1 {
|
||||||
|
transform: translateY(-1px) rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.btn-icon:hover) .link-2,
|
||||||
|
:deep(.dropdown-content button:hover) .link-2,
|
||||||
|
.icon-share:hover .link-2 {
|
||||||
|
transform: translateY(1px) rotate(-2deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-sort"
|
||||||
|
>
|
||||||
|
<path class="sort-arrow" d="M7 4v16" />
|
||||||
|
<path class="sort-arrow-head" d="M4 7l3-3 3 3" />
|
||||||
|
<path class="sort-bars bar-1" d="M13 8h7" />
|
||||||
|
<path class="sort-bars bar-2" d="M13 12h5" />
|
||||||
|
<path class="sort-bars bar-3" d="M13 16h3" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-sort {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-arrow,
|
||||||
|
.sort-arrow-head,
|
||||||
|
.sort-bars {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sort-direction-btn:hover) .sort-arrow-head,
|
||||||
|
.icon-sort:hover .sort-arrow-head {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sort-direction-btn:hover) .sort-bars.bar-1,
|
||||||
|
.icon-sort:hover .sort-bars.bar-1 {
|
||||||
|
transform: translateX(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sort-direction-btn:hover) .sort-bars.bar-2,
|
||||||
|
.icon-sort:hover .sort-bars.bar-2 {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.sort-direction-btn:hover) .sort-bars.bar-3,
|
||||||
|
.icon-sort:hover .sort-bars.bar-3 {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+102
-104
@@ -1,52 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const rootClass = computed(() => ({
|
||||||
|
'is-dark': props.isDark,
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="icon-theme-container">
|
<div class="icon-theme-container" :class="rootClass">
|
||||||
<div class="moon-glow" :class="{ 'is-active': isDark }"></div>
|
<svg class="theme-sun" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle class="sun-core" cx="12" cy="12" r="4.5" />
|
||||||
<svg
|
<path class="sun-ray ray-1" d="M12 1.8v3.2" />
|
||||||
class="icon-sun"
|
<path class="sun-ray ray-2" d="M12 19v3.2" />
|
||||||
:class="{ 'is-hidden': isDark }"
|
<path class="sun-ray ray-3" d="M4.9 4.9l2.3 2.3" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path class="sun-ray ray-4" d="M16.8 16.8l2.3 2.3" />
|
||||||
viewBox="0 0 24 24"
|
<path class="sun-ray ray-5" d="M1.8 12h3.2" />
|
||||||
fill="none"
|
<path class="sun-ray ray-6" d="M19 12h3.2" />
|
||||||
stroke="currentColor"
|
<path class="sun-ray ray-7" d="M4.9 19.1l2.3-2.3" />
|
||||||
stroke-width="2"
|
<path class="sun-ray ray-8" d="M16.8 7.2l2.3-2.3" />
|
||||||
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>
|
||||||
|
|
||||||
<svg
|
<svg class="theme-moon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
class="icon-moon"
|
<path class="moon-core" d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5Z" />
|
||||||
:class="{ 'is-visible': isDark }"
|
<path class="moon-rim" d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5Z" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path class="moon-ring ring-1" d="M16.5 6.5l.4 1.1 1.1.4-1.1.4-.4 1.1-.4-1.1-1.1-.4 1.1-.4z" />
|
||||||
viewBox="0 0 24 24"
|
<path class="moon-ring ring-2" d="M18.2 10.2l.3.9.9.3-.9.3-.3.9-.3-.9-.9-.3.9-.3z" />
|
||||||
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>
|
</svg>
|
||||||
|
|
||||||
|
<span class="theme-glow"></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -55,99 +40,112 @@ defineProps<{
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
place-items: center;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.moon-glow {
|
svg,
|
||||||
|
.theme-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
inset: 0;
|
||||||
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 {
|
svg {
|
||||||
position: absolute;
|
overflow: visible;
|
||||||
width: 100%;
|
transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.35s ease;
|
||||||
height: 100%;
|
|
||||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-sun {
|
.theme-sun {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: rotate(0) scale(1);
|
transform: rotate(0deg) scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-sun.is-hidden {
|
.theme-moon {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: rotate(90deg) scale(0.5);
|
transform: rotate(-80deg) scale(0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-moon {
|
.theme-glow {
|
||||||
|
border-radius: 50%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: rotate(-90deg) scale(0.5);
|
transform: scale(0.5);
|
||||||
|
background: radial-gradient(circle, color-mix(in srgb, currentColor 24%, transparent) 0%, transparent 72%);
|
||||||
|
transition: opacity 0.35s ease, transform 0.45s ease;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-moon.is-visible {
|
.sun-core,
|
||||||
opacity: 1;
|
.sun-ray,
|
||||||
transform: rotate(0) scale(1);
|
.moon-core,
|
||||||
}
|
.moon-rim,
|
||||||
|
.moon-ring {
|
||||||
/* Star styles */
|
stroke: currentColor;
|
||||||
.star {
|
fill: currentColor;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
opacity: 0;
|
transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-moon.is-visible .star {
|
.sun-core,
|
||||||
|
.moon-core {
|
||||||
|
stroke-width: 2;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moon-rim {
|
||||||
|
stroke-width: 2;
|
||||||
|
opacity: 0;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moon-ring {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-theme-container:hover .theme-glow {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-theme-container:hover .theme-sun {
|
||||||
|
transform: rotate(45deg) scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-theme-container:hover .sun-ray {
|
||||||
|
transform: scaleX(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-theme-container.is-dark .theme-sun {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(90deg) scale(0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-theme-container.is-dark .theme-moon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-theme-container.is-dark .moon-rim {
|
||||||
|
opacity: 0.26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-theme-container.is-dark .moon-ring {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover effects */
|
.icon-theme-container:hover .theme-moon {
|
||||||
.icon-theme-container:hover .icon-sun:not(.is-hidden) {
|
transform: rotate(-6deg) scale(1.04);
|
||||||
animation: spin 4s linear infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-theme-container:hover .icon-moon.is-visible {
|
.icon-theme-container:hover .ring-1 {
|
||||||
animation: swing 2.5s ease-in-out infinite;
|
animation: twinkle 1.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-theme-container:hover .star-1 {
|
.icon-theme-container:hover .ring-2 {
|
||||||
animation: twinkle 1.5s infinite ease-in-out;
|
animation: twinkle 1.8s ease-in-out infinite 0.15s;
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
@keyframes twinkle {
|
||||||
0%, 100% { opacity: 0.5; transform: scale(0.8); }
|
0%, 100% { transform: scale(0.85); opacity: 0.45; }
|
||||||
50% { opacity: 1; transform: scale(1.2); }
|
50% { transform: scale(1.25); opacity: 1; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="width || 24"
|
||||||
|
:height="height || 24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon-trash"
|
||||||
|
>
|
||||||
|
<path class="trash-lid" d="M3 6h18" />
|
||||||
|
<path class="trash-body" d="m6 6 1 14h10l1-14" />
|
||||||
|
<path class="trash-handle" d="M9 6V4h6v2" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon-trash {
|
||||||
|
overflow: visible;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash-lid,
|
||||||
|
.trash-body,
|
||||||
|
.trash-handle {
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.action-btn:hover) .trash-lid,
|
||||||
|
:deep(.dropdown-content button:hover) .trash-lid,
|
||||||
|
.icon-trash:hover .trash-lid {
|
||||||
|
transform: translateY(-1px) rotate(-4deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.action-btn:hover) .trash-handle,
|
||||||
|
:deep(.dropdown-content button:hover) .trash-handle,
|
||||||
|
.icon-trash:hover .trash-handle {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import type { ExtendedPreset, PresetType } from '../../types';
|
import type { ExtendedPreset, PresetType } from '../../types';
|
||||||
import IconPresetType from '../icons/IconPresetType.vue';
|
import IconPresetType from '../icons/IconPresetType.vue';
|
||||||
import IconArrowLeft from '../icons/IconArrowLeft.vue';
|
import IconArrowLeft from '../icons/IconArrowLeft.vue';
|
||||||
import IconArrowRight from '../icons/IconArrowRight.vue';
|
import IconArrowRight from '../icons/IconArrowRight.vue';
|
||||||
|
import IconHeart from '../icons/IconHeart.vue';
|
||||||
|
import IconCheck from '../icons/IconCheck.vue';
|
||||||
|
import IconCopy from '../icons/IconCopy.vue';
|
||||||
|
import IconMore from '../icons/IconMore.vue';
|
||||||
|
import IconShare from '../icons/IconShare.vue';
|
||||||
|
import IconEditor from '../icons/IconEditor.vue';
|
||||||
|
import IconTrash from '../icons/IconTrash.vue';
|
||||||
|
import IconEmptyState from '../icons/IconEmptyState.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
presets: ExtendedPreset[];
|
presets: ExtendedPreset[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
|
resetKey: string;
|
||||||
|
enableReorder?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -17,12 +27,17 @@ const emit = defineEmits<{
|
|||||||
(e: 'copy', preset: ExtendedPreset): void;
|
(e: 'copy', preset: ExtendedPreset): void;
|
||||||
(e: 'share', preset: ExtendedPreset): void;
|
(e: 'share', preset: ExtendedPreset): void;
|
||||||
(e: 'toggle-favorite', preset: ExtendedPreset): void;
|
(e: 'toggle-favorite', preset: ExtendedPreset): void;
|
||||||
|
(e: 'reorder', payload: { draggedId: string; targetId: string; side: 'before' | 'after' }): void;
|
||||||
|
(e: 'view-state-change'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Pagination Logic
|
// Pagination Logic
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const draggingPresetId = ref<string | null>(null);
|
||||||
|
const overPresetId = ref<string | null>(null);
|
||||||
|
const dropSide = ref<'before' | 'after' | null>(null);
|
||||||
|
|
||||||
const totalPages = computed(() => Math.ceil(props.presets.length / PAGE_SIZE));
|
const totalPages = computed(() => Math.ceil(props.presets.length / PAGE_SIZE));
|
||||||
|
|
||||||
@@ -32,11 +47,19 @@ const displayedPresets = computed(() => {
|
|||||||
return props.presets.slice(start, end);
|
return props.presets.slice(start, end);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.presets, () => {
|
watch(() => props.resetKey, () => {
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
containerRef.value.scrollTop = 0;
|
containerRef.value.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
emit('view-state-change');
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.presets.length, () => {
|
||||||
|
const fallbackMax = totalPages.value || 1;
|
||||||
|
if (currentPage.value > fallbackMax) {
|
||||||
|
currentPage.value = fallbackMax;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function changePage(page: number) {
|
function changePage(page: number) {
|
||||||
@@ -45,8 +68,113 @@ function changePage(page: number) {
|
|||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
containerRef.value.scrollTop = 0;
|
containerRef.value.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
emit('view-state-change');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCurrentPage(page: number, resetScroll = false) {
|
||||||
|
const fallbackMax = totalPages.value || 1;
|
||||||
|
currentPage.value = Math.min(Math.max(page, 1), fallbackMax);
|
||||||
|
if (resetScroll && containerRef.value) {
|
||||||
|
containerRef.value.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPage() {
|
||||||
|
return currentPage.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
containerRef,
|
||||||
|
setCurrentPage,
|
||||||
|
getCurrentPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function onDragStart(preset: ExtendedPreset, event: DragEvent) {
|
||||||
|
if (!props.enableReorder) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draggingPresetId.value = preset.id;
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', preset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(preset: ExtendedPreset, event: DragEvent) {
|
||||||
|
if (!props.enableReorder) return;
|
||||||
|
if (!draggingPresetId.value || draggingPresetId.value === preset.id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const target = event.currentTarget as HTMLElement | null;
|
||||||
|
if (!target) return;
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const side = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
||||||
|
overPresetId.value = preset.id;
|
||||||
|
dropSide.value = side;
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(preset: ExtendedPreset, event: DragEvent) {
|
||||||
|
if (!props.enableReorder) {
|
||||||
|
cleanupDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!draggingPresetId.value || draggingPresetId.value === preset.id || !dropSide.value) {
|
||||||
|
cleanupDragState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
emit('reorder', {
|
||||||
|
draggedId: draggingPresetId.value,
|
||||||
|
targetId: preset.id,
|
||||||
|
side: dropSide.value,
|
||||||
|
});
|
||||||
|
cleanupDragState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupDragState() {
|
||||||
|
draggingPresetId.value = null;
|
||||||
|
overPresetId.value = null;
|
||||||
|
dropSide.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
return (
|
||||||
|
tag === 'INPUT' ||
|
||||||
|
tag === 'TEXTAREA' ||
|
||||||
|
tag === 'SELECT' ||
|
||||||
|
target.isContentEditable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (totalPages.value <= 1) return;
|
||||||
|
if (isTypingTarget(event.target)) return;
|
||||||
|
|
||||||
|
if (event.key === 'PageDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
changePage(currentPage.value + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'PageUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
changePage(currentPage.value - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
function getTypeLabel(type: PresetType) {
|
function getTypeLabel(type: PresetType) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
positive: '正面',
|
positive: '正面',
|
||||||
@@ -66,68 +194,78 @@ function formatDate(dateStr: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="preset-list-container" ref="containerRef">
|
<div class="preset-list-container" ref="containerRef" @scroll="emit('view-state-change')">
|
||||||
<div v-if="presets.length === 0" class="empty-state">
|
<div v-if="presets.length === 0" class="empty-state">
|
||||||
<div class="empty-icon">📭</div>
|
<IconEmptyState class="empty-icon" />
|
||||||
<p class="empty-text">暂无预设</p>
|
<p class="empty-text">暂无预设</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<div v-if="totalPages > 1" class="pagination-controls pagination-top">
|
||||||
|
<button
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="changePage(currentPage - 1)"
|
||||||
|
class="page-btn nav-btn prev-page"
|
||||||
|
title="上一页 (PageUp)"
|
||||||
|
>
|
||||||
|
<IconArrowLeft width="16" height="16" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="page-numbers">
|
||||||
|
<span class="page-info">第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
|
||||||
|
<span class="total-count">PageUp / PageDown</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
@click="changePage(currentPage + 1)"
|
||||||
|
class="page-btn nav-btn next-page"
|
||||||
|
title="下一页 (PageDown)"
|
||||||
|
>
|
||||||
|
<IconArrowRight width="16" height="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="preset-grid">
|
<div class="preset-grid">
|
||||||
<div v-for="preset in displayedPresets" :key="preset.id" class="preset-card nav-btn">
|
<div v-for="preset in displayedPresets" :key="preset.id" class="preset-card nav-btn" :draggable="!!enableReorder"
|
||||||
|
:class="{
|
||||||
|
dragging: draggingPresetId === preset.id,
|
||||||
|
'insert-before': overPresetId === preset.id && dropSide === 'before' && draggingPresetId !== preset.id,
|
||||||
|
'insert-after': overPresetId === preset.id && dropSide === 'after' && draggingPresetId !== preset.id,
|
||||||
|
'reorder-disabled': !enableReorder
|
||||||
|
}"
|
||||||
|
@dragstart="onDragStart(preset, $event)" @dragover="onDragOver(preset, $event)"
|
||||||
|
@drop="onDrop(preset, $event)" @dragend="cleanupDragState">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="preset-type" :title="getTypeLabel(preset.type)">
|
<div class="preset-type" :title="getTypeLabel(preset.type)">
|
||||||
<IconPresetType :type="preset.type" width="24" height="24" />
|
<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">
|
||||||
<button @click="emit('toggle-favorite', preset)" class="action-btn" :class="{ 'is-favorite': preset.isFavorite }" title="收藏">
|
<button @click="emit('toggle-favorite', preset)" class="action-btn" :class="{ 'is-favorite': preset.isFavorite }" title="收藏">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" :fill="preset.isFavorite ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2">
|
<IconHeart :active="preset.isFavorite" width="14" height="14" />
|
||||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button @click="emit('apply', preset)" class="action-btn apply-btn" title="应用预设">
|
<button @click="emit('apply', preset)" class="action-btn apply-btn" title="应用到提示词">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<IconCheck width="14" height="14" />
|
||||||
<polyline points="20,6 9,17 4,12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button @click="emit('copy', preset)" class="action-btn" title="复制内容">
|
<button @click="emit('copy', preset)" class="action-btn" title="复制内容">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<IconCopy width="14" height="14" />
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
||||||
<path d="m5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<button class="action-btn more-btn">
|
<button class="action-btn more-btn">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<IconMore width="14" height="14" />
|
||||||
<circle cx="12" cy="12" r="1"/>
|
|
||||||
<circle cx="19" cy="12" r="1"/>
|
|
||||||
<circle cx="5" cy="12" r="1"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-content">
|
<div class="dropdown-content">
|
||||||
<button @click="emit('share', preset)">
|
<button @click="emit('share', preset)">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<IconShare width="14" height="14" />
|
||||||
<circle cx="18" cy="5" r="3"/>
|
|
||||||
<circle cx="6" cy="12" r="3"/>
|
|
||||||
<circle cx="18" cy="19" r="3"/>
|
|
||||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
|
||||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
|
||||||
</svg>
|
|
||||||
分享
|
分享
|
||||||
</button>
|
</button>
|
||||||
<button @click="emit('edit', preset)">
|
<button @click="emit('edit', preset)">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<IconEditor width="14" height="14" />
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
||||||
<path d="m18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
||||||
</svg>
|
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
<button @click="emit('delete', preset)" class="delete-item">
|
<button @click="emit('delete', preset)" class="delete-item">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<IconTrash width="14" height="14" />
|
||||||
<polyline points="3,6 5,6 21,6"/>
|
|
||||||
<path d="m19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
||||||
</svg>
|
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,6 +295,7 @@ function formatDate(dateStr: string) {
|
|||||||
:disabled="currentPage === 1"
|
:disabled="currentPage === 1"
|
||||||
@click="changePage(currentPage - 1)"
|
@click="changePage(currentPage - 1)"
|
||||||
class="page-btn nav-btn prev-page"
|
class="page-btn nav-btn prev-page"
|
||||||
|
title="上一页 (PageUp)"
|
||||||
>
|
>
|
||||||
<IconArrowLeft width="16" height="16" />
|
<IconArrowLeft width="16" height="16" />
|
||||||
</button>
|
</button>
|
||||||
@@ -170,6 +309,7 @@ function formatDate(dateStr: string) {
|
|||||||
:disabled="currentPage === totalPages"
|
:disabled="currentPage === totalPages"
|
||||||
@click="changePage(currentPage + 1)"
|
@click="changePage(currentPage + 1)"
|
||||||
class="page-btn nav-btn next-page"
|
class="page-btn nav-btn next-page"
|
||||||
|
title="下一页 (PageDown)"
|
||||||
>
|
>
|
||||||
<IconArrowRight width="16" height="16" />
|
<IconArrowRight width="16" height="16" />
|
||||||
</button>
|
</button>
|
||||||
@@ -200,8 +340,8 @@ function formatDate(dateStr: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-grid {
|
.preset-grid {
|
||||||
@@ -221,6 +361,7 @@ function formatDate(dateStr: string) {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-card:hover {
|
.preset-card:hover {
|
||||||
@@ -229,6 +370,23 @@ function formatDate(dateStr: string) {
|
|||||||
border-color: var(--color-border-hover);
|
border-color: var(--color-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preset-card.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-card.reorder-disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-card.insert-before {
|
||||||
|
border-top: 3px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-card.insert-after {
|
||||||
|
border-bottom: 3px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -447,6 +605,17 @@ function formatDate(dateStr: string) {
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-top {
|
||||||
|
position: sticky;
|
||||||
|
top: -1rem;
|
||||||
|
z-index: 5;
|
||||||
|
margin: -1rem -1rem 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: color-mix(in srgb, var(--color-bg-primary) 88%, transparent);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.page-btn {
|
.page-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref } from 'vue';
|
||||||
import FolderTreeItem from './FolderTreeItem.vue';
|
import FolderTreeItem from './FolderTreeItem.vue';
|
||||||
import type { PresetFolder } from '../../types';
|
import type { PresetFolder } from '../../types';
|
||||||
|
|
||||||
@@ -25,11 +25,18 @@ const emit = defineEmits<{
|
|||||||
(e: 'edit-folder', folder: PresetFolder): void;
|
(e: 'edit-folder', folder: PresetFolder): void;
|
||||||
(e: 'delete-folder', folder: PresetFolder): void;
|
(e: 'delete-folder', folder: PresetFolder): void;
|
||||||
(e: 'share-folder', folder: PresetFolder): void;
|
(e: 'share-folder', folder: PresetFolder): void;
|
||||||
|
(e: 'view-state-change'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function selectFolder(id: string | null) {
|
function selectFolder(id: string | null) {
|
||||||
emit('update:selectedFolderId', id);
|
emit('update:selectedFolderId', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
contentRef,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -45,7 +52,7 @@ function selectFolder(id: string | null) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content" ref="contentRef" @scroll="emit('view-state-change')">
|
||||||
<!-- 固定选项 -->
|
<!-- 固定选项 -->
|
||||||
<div class="system-folders">
|
<div class="system-folders">
|
||||||
<div
|
<div
|
||||||
|
|||||||
+147
-3
@@ -56,6 +56,11 @@ export const usePromptStore = defineStore('promptStore', {
|
|||||||
maxPresets: 1000
|
maxPresets: 1000
|
||||||
}
|
}
|
||||||
} as PresetManagement,
|
} as PresetManagement,
|
||||||
|
// 编辑器内的目标文件夹选择:跨页面切换保留,刷新后重置为默认(不参与持久化)
|
||||||
|
editorSelectedFolderId: '' as string,
|
||||||
|
editorFolderInitialized: false,
|
||||||
|
// 映射变更计数:用于驱动依赖翻译结果的视图(如右侧映射)的响应式刷新
|
||||||
|
mappingVersion: 0,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
categories: (s) => s.dataset?.categories ?? [],
|
categories: (s) => s.dataset?.categories ?? [],
|
||||||
@@ -275,8 +280,15 @@ export const usePromptStore = defineStore('promptStore', {
|
|||||||
addTag(groupId: string, key = 'new_tag') {
|
addTag(groupId: string, key = 'new_tag') {
|
||||||
const grp = this.findGroupById(groupId);
|
const grp = this.findGroupById(groupId);
|
||||||
if (!grp) return;
|
if (!grp) return;
|
||||||
|
const existing = new Set(grp.tags.map((t) => t.key));
|
||||||
|
let nextKey = key;
|
||||||
|
let suffix = 2;
|
||||||
|
while (existing.has(nextKey)) {
|
||||||
|
nextKey = `${key}_${suffix}`;
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
// 新增提示词插入到列表顶部,便于用户立即编辑
|
// 新增提示词插入到列表顶部,便于用户立即编辑
|
||||||
grp.tags.unshift({ key, translation: { en: key, [this.selectedLang]: key } });
|
grp.tags.unshift({ key: nextKey, translation: { en: nextKey, [this.selectedLang]: nextKey } });
|
||||||
rebuildTagIndex(this.dataset);
|
rebuildTagIndex(this.dataset);
|
||||||
},
|
},
|
||||||
removeTag(groupId: string, key: string) {
|
removeTag(groupId: string, key: string) {
|
||||||
@@ -290,9 +302,17 @@ export const usePromptStore = defineStore('promptStore', {
|
|||||||
if (!grp) return;
|
if (!grp) return;
|
||||||
const tag = grp.tags.find((t) => t.key === oldKey);
|
const tag = grp.tags.find((t) => t.key === oldKey);
|
||||||
if (!tag) return;
|
if (!tag) return;
|
||||||
tag.key = newKey;
|
const baseKey = newKey.trim() || oldKey;
|
||||||
|
const existing = new Set(grp.tags.filter((t) => t !== tag).map((t) => t.key));
|
||||||
|
let nextKey = baseKey;
|
||||||
|
let suffix = 2;
|
||||||
|
while (existing.has(nextKey)) {
|
||||||
|
nextKey = `${baseKey}_${suffix}`;
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
tag.key = nextKey;
|
||||||
if (!tag.translation) tag.translation = {};
|
if (!tag.translation) tag.translation = {};
|
||||||
tag.translation.en = newKey;
|
tag.translation.en = nextKey;
|
||||||
rebuildTagIndex(this.dataset);
|
rebuildTagIndex(this.dataset);
|
||||||
},
|
},
|
||||||
setTranslation(groupId: string, key: string, lang: LangCode, val: string) {
|
setTranslation(groupId: string, key: string, lang: LangCode, val: string) {
|
||||||
@@ -519,11 +539,13 @@ export const usePromptStore = defineStore('promptStore', {
|
|||||||
if (exist) {
|
if (exist) {
|
||||||
if (!exist.translation) exist.translation = {};
|
if (!exist.translation) exist.translation = {};
|
||||||
exist.translation[lang] = val;
|
exist.translation[lang] = val;
|
||||||
|
this.mappingVersion++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const grp = this.ensureCustomGroup();
|
const grp = this.ensureCustomGroup();
|
||||||
grp.tags.push({ key, translation: { en: key, [lang]: val } });
|
grp.tags.push({ key, translation: { en: key, [lang]: val } });
|
||||||
rebuildTagIndex(this.dataset);
|
rebuildTagIndex(this.dataset);
|
||||||
|
this.mappingVersion++;
|
||||||
},
|
},
|
||||||
ensureCustomGroup(): PromptGroup {
|
ensureCustomGroup(): PromptGroup {
|
||||||
const catName = 'Custom';
|
const catName = 'Custom';
|
||||||
@@ -833,6 +855,20 @@ export const usePromptStore = defineStore('promptStore', {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalized = [...this.extendedPresets].sort((a, b) => {
|
||||||
|
const ao = typeof a.sortOrder === 'number' ? a.sortOrder : Number.POSITIVE_INFINITY;
|
||||||
|
const bo = typeof b.sortOrder === 'number' ? b.sortOrder : Number.POSITIVE_INFINITY;
|
||||||
|
if (ao !== bo) return ao - bo;
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
});
|
||||||
|
let changed = false;
|
||||||
|
normalized.forEach((preset, index) => {
|
||||||
|
if (preset.sortOrder !== index) {
|
||||||
|
preset.sortOrder = index;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 确保有默认文件夹
|
// 确保有默认文件夹
|
||||||
if (this.presetFolders.length === 0) {
|
if (this.presetFolders.length === 0) {
|
||||||
const defaultFolder = this.createPresetFolder({
|
const defaultFolder = this.createPresetFolder({
|
||||||
@@ -845,15 +881,25 @@ export const usePromptStore = defineStore('promptStore', {
|
|||||||
if (this.presetManagement.settings) {
|
if (this.presetManagement.settings) {
|
||||||
this.presetManagement.settings.defaultFolder = defaultFolder.id;
|
this.presetManagement.settings.defaultFolder = defaultFolder.id;
|
||||||
}
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
this.save();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createExtendedPreset(data: Omit<ExtendedPreset, 'id' | 'createdAt' | 'updatedAt'>) {
|
createExtendedPreset(data: Omit<ExtendedPreset, 'id' | 'createdAt' | 'updatedAt'>) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const nextSortOrder = this.extendedPresets.reduce((max, preset) => {
|
||||||
|
const value = typeof preset.sortOrder === 'number' ? preset.sortOrder : -1;
|
||||||
|
return Math.max(max, value);
|
||||||
|
}, -1) + 1;
|
||||||
const preset: ExtendedPreset = {
|
const preset: ExtendedPreset = {
|
||||||
id: `preset_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
id: `preset_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
sortOrder: nextSortOrder,
|
||||||
...data
|
...data
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -876,6 +922,41 @@ export const usePromptStore = defineStore('promptStore', {
|
|||||||
if (index === -1) return false;
|
if (index === -1) return false;
|
||||||
|
|
||||||
this.extendedPresets.splice(index, 1);
|
this.extendedPresets.splice(index, 1);
|
||||||
|
this.extendedPresets
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.forEach((preset, idx) => {
|
||||||
|
preset.sortOrder = idx;
|
||||||
|
});
|
||||||
|
this.save();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderExtendedPresets(orderedIds: string[]) {
|
||||||
|
if (!orderedIds.length) return false;
|
||||||
|
const idSet = new Set(orderedIds);
|
||||||
|
const ordered = [...this.extendedPresets].sort((a, b) => {
|
||||||
|
const ao = typeof a.sortOrder === 'number' ? a.sortOrder : Number.POSITIVE_INFINITY;
|
||||||
|
const bo = typeof b.sortOrder === 'number' ? b.sortOrder : Number.POSITIVE_INFINITY;
|
||||||
|
if (ao !== bo) return ao - bo;
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
});
|
||||||
|
const replacements = ordered.filter(preset => idSet.has(preset.id));
|
||||||
|
if (replacements.length !== orderedIds.length) return false;
|
||||||
|
const replacementMap = new Map(replacements.map(preset => [preset.id, preset]));
|
||||||
|
const reorderedVisible = orderedIds
|
||||||
|
.map(id => replacementMap.get(id))
|
||||||
|
.filter((preset): preset is ExtendedPreset => !!preset);
|
||||||
|
|
||||||
|
let visibleIndex = 0;
|
||||||
|
const merged = ordered.map(preset => {
|
||||||
|
if (!idSet.has(preset.id)) return preset;
|
||||||
|
const nextPreset = reorderedVisible[visibleIndex];
|
||||||
|
visibleIndex += 1;
|
||||||
|
return nextPreset ?? preset;
|
||||||
|
});
|
||||||
|
merged.forEach((preset, index2) => {
|
||||||
|
preset.sortOrder = index2;
|
||||||
|
});
|
||||||
this.save();
|
this.save();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -1341,3 +1422,66 @@ export function constructToken(core: string, weight: number | undefined, wrapper
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 优先级 / 权重转换工具 =====
|
||||||
|
// () 每层的等效权重系数(沿用 A1111 约定 ×1.1)
|
||||||
|
const EMPHASIS_FACTOR = 1.1;
|
||||||
|
|
||||||
|
function roundTo(value: number, decimals: number): number {
|
||||||
|
const m = Math.pow(10, decimals);
|
||||||
|
return Math.round(value * m) / m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将权重格式化为简洁字符串(去掉多余尾随 0,例如 1.10 -> 1.1)
|
||||||
|
export function formatWeight(weight: number): string {
|
||||||
|
return String(parseFloat(weight.toFixed(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否为 <...> 形式(如 LoRA / 嵌入触发),统一时保持原样不破坏
|
||||||
|
function isAngleWrapped(token: string): boolean {
|
||||||
|
const t = normalizeSymbols(token).trim();
|
||||||
|
return t.startsWith('<') && t.endsWith('>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 token 的有效数字权重:综合显式权重与括号层级
|
||||||
|
export function getEffectiveWeight(token: string): number {
|
||||||
|
const { weight, wrappers } = parseDetailedToken(token);
|
||||||
|
const extra = [...wrappers];
|
||||||
|
let w = 1;
|
||||||
|
if (weight !== undefined) {
|
||||||
|
w = weight;
|
||||||
|
// 显式权重会消耗最内层的一个 () 作为语法容器,不再计入额外强调
|
||||||
|
const idx = extra.lastIndexOf('()');
|
||||||
|
if (idx !== -1) extra.splice(idx, 1);
|
||||||
|
}
|
||||||
|
for (const wrap of extra) {
|
||||||
|
if (wrap === '()') w *= EMPHASIS_FACTOR;
|
||||||
|
else if (wrap === '[]') w /= EMPHASIS_FACTOR;
|
||||||
|
else if (wrap === '{}') w *= 1.05;
|
||||||
|
// <> 视为中性,不计入强调
|
||||||
|
}
|
||||||
|
return roundTo(w, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一为数字权重形式:(core:weight),权重为 1 时省略
|
||||||
|
export function toNumericForm(token: string): string {
|
||||||
|
if (isAngleWrapped(token)) return normalizeSymbols(token).trim();
|
||||||
|
const { core, prefix, suffix } = parseDetailedToken(token);
|
||||||
|
if (prefix || suffix) return constructToken(core, undefined, [], prefix, suffix);
|
||||||
|
const w = roundTo(getEffectiveWeight(token), 2);
|
||||||
|
if (w === 1) return core;
|
||||||
|
return `(${core}:${formatWeight(w)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一为括号嵌套形式:根据有效权重换算 () / [] 层数
|
||||||
|
export function toBracketForm(token: string): string {
|
||||||
|
if (isAngleWrapped(token)) return normalizeSymbols(token).trim();
|
||||||
|
const { core, prefix, suffix } = parseDetailedToken(token);
|
||||||
|
if (prefix || suffix) return constructToken(core, undefined, [], prefix, suffix);
|
||||||
|
const w = getEffectiveWeight(token);
|
||||||
|
const n = Math.round(Math.log(w) / Math.log(EMPHASIS_FACTOR));
|
||||||
|
if (!Number.isFinite(n) || n === 0) return core;
|
||||||
|
const style = n > 0 ? '()' : '[]';
|
||||||
|
const layers = Array.from({ length: Math.min(Math.abs(n), 8) }, () => style);
|
||||||
|
return constructToken(core, undefined, layers);
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface ExtendedPreset {
|
|||||||
folderId?: string;
|
folderId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
sortOrder?: number;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
author?: string;
|
author?: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user