Files
prompt/src/components/PresetManager.vue
T
2025-12-03 08:59:40 +08:00

1443 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { usePromptStore } from '../stores/promptStore';
import type { ExtendedPreset, PresetFolder, PresetType } from '../types';
import NotificationToast from './NotificationToast.vue';
import PresetSidebar from './preset/PresetSidebar.vue';
import PresetList from './preset/PresetList.vue';
import FolderSelector from './preset/FolderSelector.vue';
const store = usePromptStore();
// State
const activeTab = ref<'presets' | 'folders'>('presets'); // Kept for compatibility if needed, but UI will be unified
const selectedType = ref<PresetType | 'all'>('all');
const searchQuery = ref('');
const selectedFolderId = ref<string | null>(null);
const expandedFolderIds = ref<Set<string>>(new Set());
// Dialog State
const showCreateDialog = ref(false);
const showFolderDialog = ref(false);
const editingPreset = ref<ExtendedPreset | null>(null);
const editingFolder = ref<PresetFolder | null>(null);
// Forms
const presetForm = ref({
name: '',
type: 'positive' as PresetType,
content: '',
description: '',
tags: '',
folderId: ''
});
const folderForm = ref({
name: '',
description: '',
color: '#6366f1',
parentId: ''
});
// Notification
const notification = ref<{ message: string; type: 'success' | 'error' | 'info'; show: boolean }>({
message: '',
type: 'info',
show: false
});
function showNotification(message: string, type: 'success' | 'error' | 'info' = 'info') {
notification.value = { message, type, show: true };
setTimeout(() => {
notification.value.show = false;
}, 3000);
}
// Preset Types
const presetTypes: { value: PresetType; label: string; icon: string }[] = [
{ value: 'positive', label: '正面提示词', icon: '🪄' },
{ value: 'negative', label: '负面提示词', icon: '⛔' },
{ value: 'setting', label: '设定标签', icon: '⚙️' },
{ value: 'style', label: '风格样式', icon: '🖌️' },
{ value: 'character', label: '角色人物', icon: '🧙' },
{ value: 'scene', label: '场景环境', icon: '🏞️' },
{ value: 'custom', label: '自定义', icon: '🧩' }
];
// Computed
const folderTree = computed(() => {
const folders = store.presetFolders || [];
const rootFolders = folders.filter(f => !f.parentId);
function buildTree(parentFolders: PresetFolder[]): any[] {
return parentFolders.map(folder => ({
...folder,
children: buildTree(folders.filter(f => f.parentId === folder.id)),
presetCount: (store.extendedPresets || []).filter(p => p.folderId === folder.id).length
}));
}
return buildTree(rootFolders);
});
const filteredPresets = computed(() => {
let presets = store.extendedPresets || [];
// Filter by Type
if (selectedType.value !== 'all') {
presets = presets.filter(p => p.type === selectedType.value);
}
// Filter by Folder
if (selectedFolderId.value) {
presets = presets.filter(p => p.folderId === selectedFolderId.value);
} else if (selectedFolderId.value === '') {
// Uncategorized
presets = presets.filter(p => !p.folderId);
}
// if null, show all
// Filter by Search
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase();
presets = presets.filter(p =>
p.name.toLowerCase().includes(query) ||
p.content.toLowerCase().includes(query) ||
p.description?.toLowerCase().includes(query) ||
p.tags?.some(tag => tag.toLowerCase().includes(query))
);
}
return presets.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
});
const flattenedFolders = computed(() => {
type FlatItem = { id: string; name: string; label: string; level: number; presetCount: number; hasChildren: boolean };
const res: FlatItem[] = [];
function walk(nodes: any[], level: number, parentPath: string) {
nodes.forEach((node: any) => {
const label = parentPath ? `${parentPath} / ${node.name}` : node.name;
res.push({
id: node.id,
name: node.name,
label,
level,
presetCount: node.presetCount,
hasChildren: !!(node.children && node.children.length)
});
if (node.children && node.children.length) {
walk(node.children, level + 1, label);
}
});
}
walk(folderTree.value, 0, '');
return res;
});
const allPresetsCount = computed(() => (store.extendedPresets || []).length);
const uncategorizedCount = computed(() => (store.extendedPresets || []).filter(p => !p.folderId).length);
// Actions
function handleFolderSelect(id: string | null) {
selectedFolderId.value = id;
}
function handleToggleExpand(id: string) {
const set = new Set(expandedFolderIds.value);
if (set.has(id)) set.delete(id); else set.add(id);
expandedFolderIds.value = set;
}
function createFolder(parentId?: string) {
resetFolderForm();
if (parentId) {
folderForm.value.parentId = parentId;
}
showFolderDialog.value = true;
}
function editFolder(folder: PresetFolder) {
editingFolder.value = folder;
folderForm.value = {
name: folder.name,
description: folder.description || '',
color: folder.color || '#6366f1',
parentId: folder.parentId || ''
};
showFolderDialog.value = true;
}
function saveFolder() {
if (!folderForm.value.name.trim()) {
showNotification('请填写文件夹名称', 'error');
return;
}
const folderData = {
name: folderForm.value.name.trim(),
description: folderForm.value.description.trim() || undefined,
color: folderForm.value.color,
parentId: folderForm.value.parentId || undefined
};
if (editingFolder.value) {
store.updatePresetFolder(editingFolder.value.id, folderData);
showNotification(`文件夹「${folderData.name}」已更新`, 'success');
} else {
store.createPresetFolder(folderData);
showNotification(`文件夹「${folderData.name}」已创建`, 'success');
}
closeFolderDialog();
}
function deleteFolder(folder: PresetFolder) {
const presetCount = (store.extendedPresets || []).filter(p => p.folderId === folder.id).length;
if (presetCount > 0) {
if (!confirm(`文件夹「${folder.name}」中有 ${presetCount} 个预设,删除后这些预设将移动到未分类。确定删除吗?`)) {
return;
}
} else {
if (!confirm(`确定删除文件夹「${folder.name}」吗?`)) {
return;
}
}
store.deletePresetFolder(folder.id);
if (selectedFolderId.value === folder.id) {
selectedFolderId.value = null;
}
showNotification(`文件夹「${folder.name}」已删除`, 'info');
}
function createPreset() {
resetPresetForm();
showCreateDialog.value = true;
}
function editPreset(preset: ExtendedPreset) {
editingPreset.value = preset;
presetForm.value = {
name: preset.name,
type: preset.type,
content: preset.content,
description: preset.description || '',
tags: preset.tags?.join(', ') || '',
folderId: preset.folderId || ''
};
showCreateDialog.value = true;
}
function savePreset() {
if (!presetForm.value.name.trim() || !presetForm.value.content.trim()) {
showNotification('请填写预设名称和内容', 'error');
return;
}
const presetData = {
name: presetForm.value.name.trim(),
type: presetForm.value.type,
content: presetForm.value.content.trim(),
description: presetForm.value.description.trim() || undefined,
tags: presetForm.value.tags.trim() ? presetForm.value.tags.split(',').map(t => t.trim()) : undefined,
folderId: presetForm.value.folderId || undefined
};
if (editingPreset.value) {
store.updateExtendedPreset(editingPreset.value.id, presetData);
showNotification(`预设「${presetData.name}」已更新`, 'success');
} else {
store.createExtendedPreset(presetData);
showNotification(`预设「${presetData.name}」已创建`, 'success');
}
closePresetDialog();
}
function deletePreset(preset: ExtendedPreset) {
if (confirm(`确定删除预设「${preset.name}」吗?`)) {
store.deleteExtendedPreset(preset.id);
showNotification(`预设「${preset.name}」已删除`, 'info');
}
}
async function copyPresetContent(preset: ExtendedPreset) {
try {
await navigator.clipboard.writeText(preset.content);
showNotification(`预设「${preset.name}」内容已复制到剪贴板`, 'success');
} catch (error) {
showNotification('复制失败,请手动复制', 'error');
}
}
function applyPreset(preset: ExtendedPreset) {
store.setPromptTextRaw(preset.content);
showNotification(`已应用预设「${preset.name}」`, 'success');
}
// Import/Export
function exportPresets() {
try {
const jsonData = store.exportPresetsToJson();
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `presets-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('预设已导出', 'success');
} catch (error) {
showNotification('导出失败', 'error');
}
}
function importPresets(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const jsonData = e.target?.result as string;
const success = store.importPresetsFromJson(jsonData);
if (success) {
showNotification('预设导入成功', 'success');
} else {
showNotification('导入文件格式不正确或不是预设文件', 'error');
}
} catch (error) {
showNotification('导入失败:文件格式错误', 'error');
}
};
reader.readAsText(file);
(event.target as HTMLInputElement).value = '';
}
// Share & Cloud
const showShareDialog = ref(false);
const shareTab = ref<'create' | 'import'>('create');
const shareLoading = ref(false);
const shareResultCode = ref('');
const shareImportCode = ref('');
const shareSinglePreset = ref<ExtendedPreset | null>(null);
function openShareDialog(preset?: ExtendedPreset) {
shareSinglePreset.value = preset || null;
shareTab.value = 'create';
shareResultCode.value = '';
shareImportCode.value = '';
showShareDialog.value = true;
}
function handleShare(preset: ExtendedPreset) {
openShareDialog(preset);
}
async function generateShareCode() {
shareLoading.value = true;
try {
let data;
let type = 'all';
if (shareSinglePreset.value) {
data = shareSinglePreset.value;
type = 'single';
} else {
const jsonString = store.exportPresetsToJson();
try {
data = JSON.parse(jsonString);
} catch (e) {
data = {};
}
}
const response = await fetch('https://sywb.top/api/share/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ data, type })
});
const result = await response.json();
if (result.success) {
shareResultCode.value = result.code;
showNotification('分享码生成成功', 'success');
} else {
showNotification(result.error || '生成失败', 'error');
}
} catch (error) {
console.error(error);
showNotification('网络错误,无法生成分享码', 'error');
} finally {
shareLoading.value = false;
}
}
// Import Analysis State
const showImportPreview = ref(false);
const importPreview = ref<{
totalPresets: number;
totalFolders: number;
newPresets: any[];
duplicatePresets: any[]; // Same content
conflictingPresets: any[]; // Same name, diff content
foldersToCreate: any[];
foldersToMerge: any[];
data: any;
}>({
totalPresets: 0,
totalFolders: 0,
newPresets: [],
duplicatePresets: [],
conflictingPresets: [],
foldersToCreate: [],
foldersToMerge: [],
data: null
});
const importOptions = ref({
skipDuplicates: true,
renameConflicts: true // if false, overwrite
});
async function importFromShareCode() {
if (!shareImportCode.value || shareImportCode.value.length !== 6) {
showNotification('请输入有效的6位分享码', 'error');
return;
}
shareLoading.value = true;
try {
const response = await fetch(`https://sywb.top/api/share/${shareImportCode.value}`);
const result = await response.json();
if (result.success) {
const data = result.data;
// Analyze Import
analyzeImport(data);
showImportPreview.value = true;
closeShareDialog();
} else {
showNotification(result.error || '分享码无效或已过期', 'error');
}
} catch (error) {
console.error(error);
showNotification('网络错误,无法导入', 'error');
} finally {
shareLoading.value = false;
}
}
function analyzeImport(data: any) {
const incomingPresets = Array.isArray(data.extendedPresets) ? data.extendedPresets : (Array.isArray(data.presets) ? data.presets.map((p: any) => ({
...p,
type: 'positive',
content: p.text,
description: '从旧格式导入'
})) : []);
const incomingFolders = Array.isArray(data.presetFolders) ? data.presetFolders : [];
const existingContentSet = new Set(store.extendedPresets.map(p => p.content));
const existingNameTypeSet = new Set(store.extendedPresets.map(p => `${p.name}:${p.type}`));
const existingFolderMap = new Map(store.presetFolders.map(f => [f.name, f]));
const preview = {
totalPresets: incomingPresets.length,
totalFolders: incomingFolders.length,
newPresets: [] as any[],
duplicatePresets: [] as any[],
conflictingPresets: [] as any[],
foldersToCreate: [] as any[],
foldersToMerge: [] as any[],
data
};
// Analyze Folders
for (const folder of incomingFolders) {
if (existingFolderMap.has(folder.name)) {
preview.foldersToMerge.push(folder);
} else {
preview.foldersToCreate.push(folder);
}
}
// Analyze Presets
for (const preset of incomingPresets) {
const isContentDuplicate = existingContentSet.has(preset.content);
const isNameConflict = existingNameTypeSet.has(`${preset.name}:${preset.type}`);
if (isContentDuplicate) {
preview.duplicatePresets.push(preset);
} else if (isNameConflict) {
preview.conflictingPresets.push(preset);
} else {
preview.newPresets.push(preset);
}
}
importPreview.value = preview;
}
function executeSmartImport() {
const { data, foldersToCreate, foldersToMerge } = importPreview.value;
const { skipDuplicates, renameConflicts } = importOptions.value;
// 1. Handle Folders
const idMap = new Map<string, string>();
// Map merged folders
for (const folder of foldersToMerge) {
const existing = store.presetFolders.find(f => f.name === folder.name);
if (existing) {
idMap.set(folder.id, existing.id);
}
}
// Create new folders
for (const folder of foldersToCreate) {
const newId = `folder_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
idMap.set(folder.id, newId);
const parentId = folder.parentId ? idMap.get(folder.parentId) : undefined;
store.presetFolders.push({
...folder,
id: newId,
parentId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
}
// 2. Handle Presets
const presetsToImport = [
...importPreview.value.newPresets,
...(skipDuplicates ? [] : importPreview.value.duplicatePresets),
...importPreview.value.conflictingPresets
];
let importedCount = 0;
for (const preset of presetsToImport) {
const isConflict = importPreview.value.conflictingPresets.includes(preset);
let finalName = preset.name;
if (isConflict && renameConflicts) {
finalName = `${preset.name} (Imported)`;
// Ensure unique name if (Imported) also exists
let counter = 1;
while (store.extendedPresets.some(p => p.name === finalName && p.type === preset.type)) {
counter++;
finalName = `${preset.name} (Imported ${counter})`;
}
} else if (isConflict && !renameConflicts) {
// Overwrite: Find and update
const existing = store.extendedPresets.find(p => p.name === preset.name && p.type === preset.type);
if (existing) {
Object.assign(existing, {
content: preset.content,
description: preset.description ?? existing.description,
tags: preset.tags ?? existing.tags,
updatedAt: new Date().toISOString()
});
importedCount++;
continue; // Skip creation
}
}
// Map folder ID
const mappedFolderId = preset.folderId ? idMap.get(preset.folderId) : undefined;
store.extendedPresets.push({
id: `preset_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
name: finalName,
type: preset.type,
content: preset.content,
description: preset.description,
tags: preset.tags,
folderId: mappedFolderId,
createdAt: preset.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: preset.author
});
importedCount++;
}
store.save();
showNotification(`成功导入 ${importedCount} 个预设`, 'success');
showImportPreview.value = false;
}
function closeImportPreview() {
showImportPreview.value = false;
importPreview.value.data = null;
}
function copyShareCode() {
navigator.clipboard.writeText(shareResultCode.value);
showNotification('分享码已复制', 'success');
}
function closeShareDialog() {
showShareDialog.value = false;
shareResultCode.value = '';
shareImportCode.value = '';
shareSinglePreset.value = null;
}
// Helpers
function resetPresetForm() {
presetForm.value = {
name: '',
type: 'positive',
content: '',
description: '',
tags: '',
folderId: selectedFolderId.value && selectedFolderId.value !== '' ? selectedFolderId.value : ''
};
editingPreset.value = null;
}
function resetFolderForm() {
folderForm.value = {
name: '',
description: '',
color: '#6366f1',
parentId: ''
};
editingFolder.value = null;
}
function closePresetDialog() {
showCreateDialog.value = false;
resetPresetForm();
}
function closeFolderDialog() {
showFolderDialog.value = false;
resetFolderForm();
}
onMounted(() => {
store.initializeExtendedPresets();
});
</script>
<template>
<div class="preset-manager">
<!-- Left Sidebar -->
<div class="pm-sidebar">
<PresetSidebar
:folder-tree="folderTree"
:selected-folder-id="selectedFolderId"
:expanded-ids="expandedFolderIds"
:all-count="allPresetsCount"
:uncategorized-count="uncategorizedCount"
@update:selected-folder-id="handleFolderSelect"
@toggle-expand="handleToggleExpand"
@create-folder="createFolder()"
@create-sub-folder="createFolder"
@edit-folder="editFolder"
@delete-folder="deleteFolder"
/>
</div>
<!-- Right Content -->
<div class="pm-main">
<div class="pm-toolbar">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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="搜索预设..." />
</div>
<div class="filter-group">
<select v-model="selectedType" class="type-select">
<option value="all">所有类型</option>
<option v-for="type in presetTypes" :key="type.value" :value="type.value">
{{ type.icon }} {{ type.label }}
</option>
</select>
</div>
<div class="action-group">
<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">
<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>
<div class="import-export">
<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">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path>
</svg>
</button>
<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">
<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>
<label class="btn-icon" title="导入预设">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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;">
</label>
</div>
</div>
</div>
<div class="pm-content-area">
<PresetList
:presets="filteredPresets"
:search-query="searchQuery"
@apply="applyPreset"
@edit="editPreset"
@delete="deletePreset"
@copy="copyPresetContent"
@share="handleShare"
/>
</div>
</div>
<!-- Create/Edit Preset Modal -->
<div v-if="showCreateDialog" class="modal-overlay" @click.self="closePresetDialog">
<div class="modal-content">
<div class="modal-header">
<h3>{{ editingPreset ? '编辑预设' : '新建预设' }}</h3>
<button @click="closePresetDialog" class="close-btn">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>名称</label>
<input v-model="presetForm.name" placeholder="给预设起个名字" />
</div>
<div class="form-row">
<div class="form-group">
<label>类型</label>
<select v-model="presetForm.type">
<option v-for="t in presetTypes" :key="t.value" :value="t.value">
{{ t.icon }} {{ t.label }}
</option>
</select>
</div>
<div class="form-group">
<label>文件夹</label>
<FolderSelector
v-model="presetForm.folderId"
:tree="folderTree"
:flattened="flattenedFolders"
root-label="( - 未分类)"
/>
</div>
</div>
<div class="form-group">
<label>内容</label>
<textarea v-model="presetForm.content" rows="10" placeholder="预设的提示词内容..."></textarea>
</div>
<div class="form-group">
<label>描述 (选填)</label>
<input v-model="presetForm.description" placeholder="简短描述" />
</div>
<div class="form-group">
<label>标签 (选填逗号分隔)</label>
<input v-model="presetForm.tags" placeholder="tag1, tag2, tag3" />
</div>
</div>
<div class="modal-footer">
<button @click="closePresetDialog" class="btn-secondary">取消</button>
<button @click="savePreset" class="btn-primary">保存</button>
</div>
</div>
</div>
<!-- Create/Edit Folder Modal -->
<div v-if="showFolderDialog" class="modal-overlay" @click.self="closeFolderDialog">
<div class="modal-content">
<div class="modal-header">
<h3>{{ editingFolder ? '编辑文件夹' : '新建文件夹' }}</h3>
<button @click="closeFolderDialog" class="close-btn">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>名称</label>
<input v-model="folderForm.name" placeholder="文件夹名称" />
</div>
<div class="form-group">
<label>父文件夹</label>
<FolderSelector
v-model="folderForm.parentId"
:tree="folderTree"
:flattened="flattenedFolders"
:exclude-id="editingFolder?.id"
root-label="( - 根文件夹)"
/>
</div>
<div class="form-group">
<label>描述 (选填)</label>
<input v-model="folderForm.description" placeholder="简短描述" />
</div>
<div class="form-group">
<label>颜色标记</label>
<div class="color-picker">
<div
v-for="color in ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#64748b']"
:key="color"
class="color-option"
:style="{ backgroundColor: color }"
:class="{ active: folderForm.color === color }"
@click="folderForm.color = color"
></div>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="closeFolderDialog" class="btn-secondary">取消</button>
<button @click="saveFolder" class="btn-primary">保存</button>
</div>
</div>
</div>
<!-- Share/Import Modal -->
<div v-if="showShareDialog" class="modal-overlay" @click.self="closeShareDialog">
<div class="modal-content share-modal">
<div class="modal-header">
<h3>云端分享与导入</h3>
<button @click="closeShareDialog" class="close-btn">×</button>
</div>
<div class="share-tabs">
<button
:class="{ active: shareTab === 'create' }"
@click="shareTab = 'create'"
>
创建分享
</button>
<button
:class="{ active: shareTab === 'import' }"
@click="shareTab = 'import'"
>
导入预设
</button>
</div>
<div class="modal-body">
<!-- Create Share -->
<div v-if="shareTab === 'create'" class="share-panel">
<div class="share-info">
<p v-if="shareSinglePreset">
正在分享预设: <strong>{{ shareSinglePreset.name }}</strong>
</p>
<p v-else>
正在分享: <strong>所有预设数据</strong>
</p>
<p class="text-muted">生成一个6位数的分享码有效期24小时</p>
</div>
<div v-if="shareResultCode" class="share-result">
<div class="code-display">{{ shareResultCode }}</div>
<button @click="copyShareCode" class="btn-secondary">复制分享码</button>
</div>
<div v-else class="share-action">
<button
@click="generateShareCode"
class="btn-primary full-width"
:disabled="shareLoading"
>
{{ shareLoading ? '生成中...' : '生成分享码' }}
</button>
</div>
</div>
<!-- Import Share -->
<div v-if="shareTab === 'import'" class="share-panel">
<div class="form-group">
<label>输入6位分享码</label>
<input
v-model="shareImportCode"
placeholder="例如: 123456"
maxlength="6"
class="code-input"
/>
</div>
<div class="share-action">
<button
@click="importFromShareCode"
class="btn-primary full-width"
:disabled="shareLoading || shareImportCode.length !== 6"
>
{{ shareLoading ? '导入中...' : '导入' }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Import Preview Dialog -->
<div v-if="showImportPreview" class="modal-overlay" @click.self="closeImportPreview">
<div class="modal-content import-preview-modal">
<div class="modal-header">
<h3>导入预览</h3>
<button @click="closeImportPreview" class="close-btn">×</button>
</div>
<div class="modal-body">
<div class="preview-summary">
<div class="summary-item">
<div class="label">找到预设</div>
<div class="value">{{ importPreview.totalPresets }}</div>
</div>
<div class="summary-item">
<div class="label">找到文件夹</div>
<div class="value">{{ importPreview.totalFolders }}</div>
</div>
</div>
<div class="preview-details">
<div class="detail-group success">
<h4> 新增 ({{ importPreview.newPresets.length }})</h4>
<div class="detail-list" v-if="importPreview.newPresets.length > 0">
<span v-for="p in importPreview.newPresets.slice(0, 3)" :key="p.id || p.name">{{ p.name }}</span>
<span v-if="importPreview.newPresets.length > 3">...等 {{ importPreview.newPresets.length }} </span>
</div>
</div>
<div class="detail-group warning" v-if="importPreview.conflictingPresets.length > 0">
<h4>⚠️ 命名冲突 ({{ importPreview.conflictingPresets.length }})</h4>
<p class="help-text">名称相同但内容不同</p>
<div class="detail-list">
<span v-for="p in importPreview.conflictingPresets.slice(0, 3)" :key="p.id || p.name">{{ p.name }}</span>
<span v-if="importPreview.conflictingPresets.length > 3">...等</span>
</div>
<div class="option-check">
<label>
<input type="checkbox" v-model="importOptions.renameConflicts">
自动重命名 (添加 Imported 后缀)
</label>
<div class="sub-text" v-if="!importOptions.renameConflicts">将覆盖现有同名预设的内容</div>
</div>
</div>
<div class="detail-group info" v-if="importPreview.duplicatePresets.length > 0">
<h4>♻️ 完全重复 ({{ importPreview.duplicatePresets.length }})</h4>
<p class="help-text">内容完全一致</p>
<div class="option-check">
<label>
<input type="checkbox" v-model="importOptions.skipDuplicates">
跳过导入 (推荐)
</label>
</div>
</div>
<div class="detail-group info" v-if="importPreview.foldersToCreate.length > 0">
<h4>📁 文件夹结构</h4>
<p>将创建 {{ importPreview.foldersToCreate.length }} 个新文件夹合并 {{ importPreview.foldersToMerge.length }} 个现有文件夹</p>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="closeImportPreview" class="btn-secondary">取消</button>
<button @click="executeSmartImport" class="btn-primary">确认导入</button>
</div>
</div>
</div>
<!-- Toast Notification -->
<NotificationToast
:message="notification.message"
:type="notification.type"
:show="notification.show"
/>
</div>
</template>
<style scoped>
.preset-manager {
display: flex;
width: 100%;
max-width: 1400px;
margin: 0 auto;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
overflow: hidden;
}
.pm-sidebar {
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--color-border);
height: 100%;
}
.pm-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
}
.pm-toolbar {
padding: 1rem;
display: flex;
align-items: center;
gap: 1rem;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-secondary);
}
.search-box {
position: relative;
flex: 1;
max-width: 400px;
}
.search-box svg {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-tertiary);
}
.search-box input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
.filter-group {
display: flex;
gap: 0.5rem;
}
.type-select {
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
.action-group {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.75rem;
}
.import-export {
display: flex;
gap: 0.25rem;
border-left: 1px solid var(--color-border);
padding-left: 0.75rem;
}
.pm-content-area {
flex: 1;
overflow: hidden;
position: relative;
}
/* Buttons */
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
padding: 0.5rem 1rem;
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.btn-secondary:hover {
background-color: var(--color-bg-secondary);
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: transparent;
border: none;
color: var(--color-text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
}
.btn-icon:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(2px);
}
.modal-content {
background-color: var(--color-bg-primary);
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
width: 90%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: modal-in 0.2s ease-out;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.modal-header {
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.close-btn {
background: transparent;
border: none;
font-size: 1.75rem;
color: var(--color-text-tertiary);
cursor: pointer;
line-height: 1;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.close-btn:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-secondary);
}
.modal-body {
padding: 1.75rem;
overflow-y: auto;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
font-family: inherit;
font-size: 0.95rem;
transition: all 0.2s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
background-color: var(--color-bg-primary);
}
.form-group textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.9rem;
line-height: 1.6;
}
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.color-picker {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.color-option {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.active {
border-color: var(--color-text-primary);
box-shadow: 0 0 0 2px var(--color-bg-primary);
}
.share-modal {
max-width: 400px;
}
.share-tabs {
display: flex;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.share-tabs button {
flex: 1;
padding: 0.75rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-weight: 500;
}
.share-tabs button.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.share-info {
text-align: center;
margin-bottom: 1.5rem;
color: var(--color-text-primary);
}
.share-info p {
margin: 0.5rem 0;
}
.text-muted {
color: var(--color-text-tertiary);
font-size: 0.875rem;
}
.share-result {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.code-display {
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.25rem;
color: var(--color-accent);
padding: 1rem;
background-color: var(--color-bg-secondary);
border-radius: var(--radius-md);
width: 100%;
text-align: center;
}
.code-input {
font-size: 1.5rem;
text-align: center;
letter-spacing: 0.25rem;
padding: 0.75rem;
}
.full-width {
width: 100%;
justify-content: center;
padding: 0.75rem;
}
/* Import Preview Styles */
.import-preview-modal {
max-width: 600px;
}
.preview-summary {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background-color: var(--color-bg-secondary);
border-radius: var(--radius-md);
}
.summary-item {
flex: 1;
text-align: center;
}
.summary-item .label {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.summary-item .value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-primary);
}
.preview-details {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 400px;
overflow-y: auto;
}
.detail-group {
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
}
.detail-group.success { border-left: 4px solid #10b981; }
.detail-group.warning { border-left: 4px solid #f59e0b; }
.detail-group.info { border-left: 4px solid #3b82f6; }
.detail-group h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.detail-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.detail-list span {
background-color: var(--color-bg-secondary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.help-text {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin: 0 0 0.5rem 0;
}
.option-check {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
.option-check label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
}
.sub-text {
font-size: 0.75rem;
color: var(--color-text-tertiary);
margin-left: 1.5rem;
margin-top: 0.25rem;
}
</style>