样式美化-预设

This commit is contained in:
2025-11-29 09:03:12 +08:00
parent f292230c13
commit 37087c7238
8 changed files with 1721 additions and 1153 deletions
+92 -68
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { usePromptStore } from '../stores/promptStore';
import type { PromptPreset } from '../types';
import type { PromptPreset, PresetType } from '../types';
import NotificationToast from './NotificationToast.vue';
const store = usePromptStore();
@@ -48,21 +48,28 @@ const filteredPresets = computed(() => {
const q = presetSearch.value.trim().toLowerCase();
// 合并旧预设和新预设,优先显示新预设
let list = [
...store.extendedPresets.map(p => ({
...store.extendedPresets.map(p => {
const folder = p.folderId ? store.presetFolders.find(f => f.id === p.folderId) : null;
return {
name: p.name,
text: p.content,
updatedAt: p.updatedAt,
type: p.type,
description: p.description,
isExtended: true
})),
isExtended: true,
folderId: p.folderId,
folderName: folder ? folder.name : '未分类'
};
}),
...store.presets.map(p => ({
name: p.name,
text: p.text,
updatedAt: p.updatedAt,
type: 'positive' as const,
description: undefined,
isExtended: false
isExtended: false,
folderId: null,
folderName: '未分类'
}))
];
@@ -127,6 +134,31 @@ const filteredPresets = computed(() => {
return list;
});
const groupedPresets = computed(() => {
const list = filteredPresets.value;
// 如果有搜索或者排序不是默认的,使用平铺列表(视为一个组)
if (presetSearch.value || sortBy.value !== 'date') {
return [{ name: presetSearch.value ? '搜索结果' : '所有预设', presets: list }];
}
// 分组
const groups: Record<string, typeof list> = {};
list.forEach(p => {
const key = p.folderName || '未分类';
if (!groups[key]) groups[key] = [];
groups[key].push(p);
});
// 转换为数组并排序
return Object.entries(groups)
.map(([name, presets]) => ({ name, presets }))
.sort((a, b) => {
if (a.name === '未分类') return 1;
if (b.name === '未分类') return -1;
return a.name.localeCompare(b.name);
});
});
const presetStats = computed(() => {
const totalOld = store.presets.length;
const totalExtended = store.extendedPresets.length;
@@ -293,6 +325,19 @@ function importPreset(event: Event) {
(event.target as HTMLInputElement).value = '';
}
function getTypeIcon(type: string) {
const icons: Record<string, string> = {
positive: '👍',
negative: '👎',
setting: '⚙️',
style: '🎨',
character: '👤',
scene: '🌍',
custom: '📝'
};
return icons[type] || '📝';
}
function getTypeLabel(type: string) {
const typeMap: Record<string, string> = {
'positive': '正面',
@@ -462,20 +507,23 @@ onUnmounted(() => {
<span>{{ presetSearch ? '未找到匹配的预设' : '暂无预设' }}</span>
</div>
<div v-for="p in filteredPresets" :key="`${p.name}_${p.type}`" class="pd-item">
<div v-for="group in groupedPresets" :key="group.name" class="pd-group">
<div v-if="groupedPresets.length > 1 || group.name !== '所有预设'" class="pd-group-header">
{{ group.name }}
<span class="pd-group-count">{{ group.presets.length }}</span>
</div>
<div v-for="p in group.presets" :key="`${p.name}_${p.type}`" class="pd-item">
<template v-if="renamingPreset !== p.name">
<div class="pd-item-main" @click="loadPreset(p.name)">
<div class="pd-item-header">
<div class="pd-item-title">
<span class="pd-item-icon" :title="getTypeLabel(p.type)">{{ getTypeIcon(p.type) }}</span>
<span class="pd-item-name">{{ p.name }}</span>
<span v-if="p.isExtended" class="pd-item-type" :class="`type-${p.type}`">
{{ getTypeLabel(p.type) }}
</span>
</div>
<span class="pd-item-date">{{ formatDate(p.updatedAt) }}</span>
</div>
<div class="pd-item-preview">{{ getPresetPreview(p.text) }}</div>
<div v-if="p.description" class="pd-item-description">{{ p.description }}</div>
</div>
<div class="pd-item-actions">
@@ -533,6 +581,7 @@ onUnmounted(() => {
</template>
</div>
</div>
</div>
<!-- 底部提示 -->
<div class="pd-footer">
@@ -758,7 +807,7 @@ onUnmounted(() => {
.pd-list {
flex: 1;
overflow-y: auto;
max-height: 300px;
max-height: 350px;
}
.pd-empty {
@@ -778,10 +827,33 @@ onUnmounted(() => {
opacity: 0.5;
}
.pd-group-header {
padding: 0.5rem 1rem;
background-color: var(--color-bg-tertiary);
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 1;
}
.pd-group-count {
background-color: var(--color-bg-primary);
padding: 0.125rem 0.375rem;
border-radius: 99px;
font-size: 0.6875rem;
color: var(--color-text-tertiary);
}
.pd-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--color-border);
transition: all 0.2s ease;
}
@@ -802,7 +874,7 @@ onUnmounted(() => {
.pd-item-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
@@ -822,52 +894,14 @@ onUnmounted(() => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.pd-item-type {
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
font-weight: 500;
.pd-item-icon {
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
}
.pd-item-type.type-positive {
background-color: #dcfce7;
color: #166534;
}
.pd-item-type.type-negative {
background-color: #fee2e2;
color: #991b1b;
}
.pd-item-type.type-setting {
background-color: #e0e7ff;
color: #3730a3;
}
.pd-item-type.type-style {
background-color: #fef3c7;
color: #92400e;
}
.pd-item-type.type-character {
background-color: #f3e8ff;
color: #6b21a8;
}
.pd-item-type.type-scene {
background-color: #ecfdf5;
color: #047857;
}
.pd-item-type.type-custom {
background-color: #f1f5f9;
color: #475569;
}
.pd-item-date {
font-size: 0.75rem;
color: var(--color-text-tertiary);
@@ -878,29 +912,19 @@ onUnmounted(() => {
.pd-item-preview {
font-size: 0.75rem;
color: var(--color-text-secondary);
line-height: 1.3;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.25rem;
}
.pd-item-description {
font-size: 0.6875rem;
color: var(--color-text-tertiary);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-style: italic;
opacity: 0.8;
}
.pd-item-actions {
display: flex;
gap: 0.25rem;
opacity: 0.7;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: 0.5rem;
margin-left: 0.75rem;
}
.pd-item:hover .pd-item-actions {
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -631,6 +631,7 @@ function isRemoveDisabled(token: string): boolean {
<template>
<div class="pe-root">
<header class="pe-toolbar">
<div class="pe-toolbar-content">
<div class="pe-left">
<label>语言</label>
<select v-model="selectedLang">
@@ -686,6 +687,7 @@ function isRemoveDisabled(token: string): boolean {
/>
</div>
</div>
</div>
</header>
<main class="pe-main">
@@ -972,12 +974,18 @@ function isRemoveDisabled(token: string): boolean {
}
.pe-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background-color: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.pe-toolbar-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 1400px;
margin: 0 auto;
gap: 1rem;
flex-wrap: wrap;
}
@@ -1229,6 +1237,9 @@ function isRemoveDisabled(token: string): boolean {
height: calc(100vh - 8rem);
gap: 1px;
background-color: var(--color-border);
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.pe-left-pane, .pe-right-pane {
+325 -66
View File
@@ -114,13 +114,8 @@ function resetDefault() {
<select v-model="selectedLang" class="pm-select pm-lang">
<option v-for="l in languages" :key="l" :value="l">{{ l }}</option>
</select>
<input
class="pm-search"
type="search"
placeholder="搜索关键字/翻译"
:value="store.searchQuery"
@input="store.setSearch(($event.target as HTMLInputElement).value)"
/>
<input class="pm-search" type="search" placeholder="搜索关键字/翻译" :value="store.searchQuery"
@input="store.setSearch(($event.target as HTMLInputElement).value)" />
</div>
<div class="pm-right">
<button class="pm-btn" @click="exportAll">导出 JSON</button>
@@ -136,13 +131,15 @@ function resetDefault() {
<aside class="pm-cats">
<div class="pm-section-title">分类</div>
<ul>
<li v-for="(c,ci) in categories" :key="c.id" :class="{ active: ci===store.selectedCategoryIndex }" @click="store.selectCategory(ci)">
<li v-for="(c, ci) in categories" :key="c.id" :class="{ active: ci === store.selectedCategoryIndex }"
@click="store.selectCategory(ci)">
{{ c.name }}
</li>
</ul>
<div class="pm-section-title">分组</div>
<ul>
<li v-for="(g,gi) in currentCategory?.groups" :key="g.id" :class="{ active: gi===store.selectedGroupIndex }" @click="store.selectGroup(gi)">
<li v-for="(g, gi) in currentCategory?.groups" :key="g.id" :class="{ active: gi === store.selectedGroupIndex }"
@click="store.selectGroup(gi)">
<span class="pm-color" :style="{ background: g.color || 'transparent' }"></span>
{{ g.name }}
</li>
@@ -155,18 +152,13 @@ function resetDefault() {
</div>
<div v-if="!currentGroup" class="pm-empty">请选择一个分组</div>
<ul v-else class="pm-tags">
<li
v-for="(t,ti) in filteredTags"
:key="t.key + '_' + ti"
:draggable="true"
@dragstart="onDragStart(ti)"
@dragover="onDragOver(ti, $event)"
@drop="onDrop(ti)"
:class="{ hidden: t.hidden, 'pm-over': ti===overIndex }"
>
<li v-for="(t, ti) in filteredTags" :key="t.key + '_' + ti" :draggable="true" @dragstart="onDragStart(ti)"
@dragover="onDragOver(ti, $event)" @drop="onDrop(ti)"
:class="{ hidden: t.hidden, 'pm-over': ti === overIndex }">
<span class="pm-handle"></span>
<input class="pm-key" :value="t.key" @input="updateKey(t, ($event.target as HTMLInputElement).value)" />
<input class="pm-trans" :value="displayTranslation(t)" @input="updateTrans(t, ($event.target as HTMLInputElement).value)" />
<input class="pm-trans" :value="displayTranslation(t)"
@input="updateTrans(t, ($event.target as HTMLInputElement).value)" />
<button class="pm-hide" @click="toggleHidden(t)">{{ t.hidden ? '显示' : '隐藏' }}</button>
<button class="pm-del" @click="removeTag(t)">删除</button>
</li>
@@ -178,59 +170,326 @@ function resetDefault() {
</template>
<style scoped>
.pm-root { display: flex; flex-direction: column; height: 100vh; }
.pm-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid var(--color-border); gap: 12px; background-color: var(--color-bg-primary); }
.pm-left { display: flex; align-items: center; gap: 8px; }
.pm-right { display: flex; align-items: center; gap: 8px; }
.pm-tip { font-size: 12px; color: var(--color-text-tertiary); }
.pm-root {
display: flex;
width: 100%;
max-width: 1400px;
margin: 0 auto;
flex-direction: column;
height: 100vh;
}
.pm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
gap: 12px;
background-color: var(--color-bg-primary);
}
.pm-left {
display: flex;
align-items: center;
gap: 8px;
}
.pm-right {
display: flex;
align-items: center;
gap: 8px;
}
.pm-tip {
font-size: 12px;
color: var(--color-text-tertiary);
}
/* 按钮与输入样式,与 PresetManager 保持一致 */
.pm-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-bg-primary); cursor: pointer; line-height: 1; font-size: 14px; color: var(--color-text-primary); transition: all 0.2s ease; }
.pm-btn:hover { background-color: var(--color-bg-tertiary); border-color: var(--color-border-hover); }
.pm-btn-primary, .pm-btn-secondary { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid var(--color-border); border-radius: var(--radius-md); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; line-height: 1; }
.pm-btn-primary { background-color: var(--color-accent); color: var(--color-text-primary); border-color: var(--color-accent); }
.pm-btn-primary:hover { background-color: var(--color-accent-hover); }
.pm-btn-secondary { background-color: var(--color-bg-primary); color: var(--color-text-primary); }
.pm-btn-secondary:hover { background-color: var(--color-bg-tertiary); border-color: var(--color-border-hover); }
.pm-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
cursor: pointer;
line-height: 1;
font-size: 14px;
color: var(--color-text-primary);
transition: all 0.2s ease;
}
.pm-import { display: inline-flex; align-items: center; gap: 6px; }
.pm-import input { display: none; }
.pm-search { width: 240px; padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background-color: var(--color-bg-secondary); color: var(--color-text-primary); }
.pm-search:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-light); }
.pm-select { appearance: none; -webkit-appearance: none; -moz-appearance: none; background-color: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 6px 10px; height: 32px; line-height: 20px; font-size: 14px; color: var(--color-text-primary); }
.pm-select:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-light); }
.pm-btn:hover {
background-color: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
.pm-main { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 50px); }
.pm-cats { border-right: 1px solid var(--color-border); overflow: auto; padding: 8px; background-color: var(--color-bg-primary); }
.pm-section-title { font-size: 12px; color: var(--color-text-tertiary); margin: 8px 0; }
.pm-cats ul { list-style: none; margin: 0; padding: 0; }
.pm-cats li { padding: 6px 8px; border-radius: var(--radius-md); cursor: pointer; }
.pm-cats li.active { background-color: var(--color-accent-light); }
.pm-color { display: inline-block; width: 12px; height: 12px; border-radius: 2px; margin-right: 6px; vertical-align: middle; }
.pm-list { padding: 8px; overflow: auto; }
.pm-list-toolbar { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-bottom: 8px; }
.pm-empty { color: var(--color-text-tertiary); padding: 20px; }
.pm-tags { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.pm-tags li { display: grid; grid-template-columns: 24px 1fr 1fr auto auto; align-items: center; gap: 6px; padding: 6px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background-color: var(--color-bg-primary); }
.pm-tags li { will-change: transform; }
.pm-tags li.hidden { opacity: 0.5; }
.pm-tags li.pm-over { border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-light); }
.pm-handle { cursor: grab; user-select: none; color: var(--color-text-tertiary); text-align: center; }
.pm-key, .pm-trans { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background-color: var(--color-bg-secondary); color: var(--color-text-primary); }
.pm-key:focus, .pm-trans:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-light); }
.pm-hide, .pm-del, .pm-list-toolbar button, .pm-right button { padding: 6px 10px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-bg-primary); cursor: pointer; line-height: 1; }
.pm-hide:hover, .pm-del:hover, .pm-list-toolbar button:hover, .pm-right button:hover, .pm-btn:hover { background: var(--color-bg-tertiary); border-color: var(--color-border-hover); }
.pm-btn-primary,
.pm-btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
line-height: 1;
}
.pm-btn-primary {
background-color: var(--color-accent);
color: var(--color-text-primary);
border-color: var(--color-accent);
}
.pm-btn-primary:hover {
background-color: var(--color-accent-hover);
}
.pm-btn-secondary {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
.pm-btn-secondary:hover {
background-color: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
.pm-import {
display: inline-flex;
align-items: center;
gap: 6px;
}
.pm-import input {
display: none;
}
.pm-search {
width: 240px;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.pm-search:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pm-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 6px 10px;
height: 32px;
line-height: 20px;
font-size: 14px;
color: var(--color-text-primary);
}
.pm-select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pm-main {
display: grid;
grid-template-columns: 280px 1fr;
height: calc(100vh - 50px);
}
.pm-cats {
border-right: 1px solid var(--color-border);
overflow: auto;
padding: 8px;
background-color: var(--color-bg-primary);
}
.pm-section-title {
font-size: 12px;
color: var(--color-text-tertiary);
margin: 8px 0;
}
.pm-cats ul {
list-style: none;
margin: 0;
padding: 0;
}
.pm-cats li {
padding: 6px 8px;
border-radius: var(--radius-md);
cursor: pointer;
}
.pm-cats li.active {
background-color: var(--color-accent-light);
}
.pm-color {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 6px;
vertical-align: middle;
}
.pm-list {
padding: 8px;
overflow: auto;
}
.pm-list-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.pm-empty {
color: var(--color-text-tertiary);
padding: 20px;
}
.pm-tags {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.pm-tags li {
display: grid;
grid-template-columns: 24px 1fr 1fr auto auto;
align-items: center;
gap: 6px;
padding: 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
}
.pm-tags li {
will-change: transform;
}
.pm-tags li.hidden {
opacity: 0.5;
}
.pm-tags li.pm-over {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pm-handle {
cursor: grab;
user-select: none;
color: var(--color-text-tertiary);
text-align: center;
}
.pm-key,
.pm-trans {
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.pm-key:focus,
.pm-trans:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pm-hide,
.pm-del,
.pm-list-toolbar button,
.pm-right button {
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
cursor: pointer;
line-height: 1;
}
.pm-hide:hover,
.pm-del:hover,
.pm-list-toolbar button:hover,
.pm-right button:hover,
.pm-btn:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
/* 手机端适配 */
@media (max-width: 768px) {
.pm-toolbar { flex-direction: column; align-items: stretch; gap: 10px; }
.pm-left, .pm-right { flex-wrap: wrap; width: 100%; }
.pm-search { width: 100%; }
.pm-tip { display: none; }
.pm-main { grid-template-columns: 1fr; height: auto; }
.pm-cats { border-right: 0; border-bottom: 1px solid var(--color-border); }
.pm-tags li { grid-template-columns: 1fr; gap: 8px; }
.pm-handle { text-align: left; }
.pm-key, .pm-trans { width: 100%; }
.pm-toolbar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.pm-left,
.pm-right {
flex-wrap: wrap;
width: 100%;
}
.pm-search {
width: 100%;
}
.pm-tip {
display: none;
}
.pm-main {
grid-template-columns: 1fr;
height: auto;
}
.pm-cats {
border-right: 0;
border-bottom: 1px solid var(--color-border);
}
.pm-tags li {
grid-template-columns: 1fr;
gap: 8px;
}
.pm-handle {
text-align: left;
}
.pm-key,
.pm-trans {
width: 100%;
}
}
</style>
+237
View File
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, ref, nextTick } from 'vue';
import type { PresetFolder } from '../../types';
// 定义树节点类型,包含 children
interface FolderNode extends PresetFolder {
children: FolderNode[];
presetCount: number;
}
const props = defineProps<{
folder: FolderNode;
level: number;
selectedFolderId: string | null;
expandedIds: Set<string>;
}>();
const emit = defineEmits<{
(e: 'select', id: string): void;
(e: 'toggle', id: string): void;
(e: 'create-sub', parentId: string): void;
(e: 'edit', folder: PresetFolder): void;
(e: 'delete', folder: PresetFolder): void;
}>();
const isExpanded = computed(() => props.expandedIds.has(props.folder.id));
const isSelected = computed(() => props.selectedFolderId === props.folder.id);
function handleToggle(e: Event) {
e.stopPropagation();
emit('toggle', props.folder.id);
}
function handleSelect() {
emit('select', props.folder.id);
}
// 上下文菜单或操作按钮
const showActions = ref(false);
</script>
<template>
<div class="folder-tree-item">
<div
class="folder-row"
:class="{ active: isSelected }"
:style="{ paddingLeft: `${level * 1.2 + 0.5}rem` }"
@click="handleSelect"
@mouseenter="showActions = true"
@mouseleave="showActions = false"
>
<!-- 展开/折叠箭头 -->
<button
class="toggle-btn"
:class="{ invisible: !folder.children.length }"
@click="handleToggle"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
:class="{ rotated: isExpanded }"
class="arrow-icon"
>
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<!-- 文件夹图标 -->
<span class="folder-icon" :style="{ color: folder.color || '#6366f1' }">
<svg v-if="isExpanded" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="currentColor" fill-opacity="0.2" stroke="currentColor" stroke-width="2"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="currentColor" fill-opacity="0.2" stroke="currentColor" stroke-width="2"/>
</svg>
</span>
<!-- 文件夹名称 -->
<span class="folder-name">{{ folder.name }}</span>
<!-- 计数 -->
<span class="folder-count" v-if="folder.presetCount > 0">{{ folder.presetCount }}</span>
<!-- 操作按钮组 (悬停显示) -->
<div class="folder-actions" v-show="showActions || isSelected">
<button @click.stop="emit('create-sub', folder.id)" title="新建子文件夹">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
<button @click.stop="emit('edit', folder)" title="编辑文件夹">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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 @click.stop="emit('delete', folder)" class="delete-btn" title="删除文件夹">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 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>
</div>
</div>
<!-- 递归渲染子节点 -->
<div v-if="isExpanded && folder.children.length" class="folder-children">
<FolderTreeItem
v-for="child in folder.children"
:key="child.id"
:folder="child"
:level="level + 1"
:selected-folder-id="selectedFolderId"
:expanded-ids="expandedIds"
@select="emit('select', $event)"
@toggle="emit('toggle', $event)"
@create-sub="emit('create-sub', $event)"
@edit="emit('edit', $event)"
@delete="emit('delete', $event)"
/>
</div>
</div>
</template>
<style scoped>
.folder-row {
display: flex;
align-items: center;
padding: 0.375rem 0.5rem;
cursor: pointer;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
transition: all 0.1s ease;
position: relative;
user-select: none;
height: 2rem;
}
.folder-row:hover {
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.folder-row.active {
background-color: var(--color-accent-light);
color: var(--color-accent);
}
/* 适配暗色模式下的选中状态 */
:global(.dark) .folder-row.active {
background-color: rgba(59, 130, 246, 0.2);
color: var(--color-accent);
}
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
margin-right: 0.25rem;
}
.toggle-btn.invisible {
opacity: 0;
pointer-events: none;
}
.arrow-icon {
transition: transform 0.2s ease;
}
.arrow-icon.rotated {
transform: rotate(90deg);
}
.folder-icon {
display: flex;
align-items: center;
margin-right: 0.5rem;
}
.folder-name {
flex: 1;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-count {
font-size: 0.75rem;
color: var(--color-text-tertiary);
margin-left: 0.5rem;
background-color: var(--color-bg-tertiary);
padding: 0 0.375rem;
border-radius: 99px;
min-width: 1.25rem;
text-align: center;
}
.folder-actions {
display: flex;
gap: 0.25rem;
margin-left: 0.5rem;
}
.folder-actions button {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
background: transparent;
color: var(--color-text-tertiary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
}
.folder-actions button:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.folder-actions button.delete-btn:hover {
color: var(--color-error);
background-color: rgba(239, 68, 68, 0.1);
}
</style>
+342
View File
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ExtendedPreset, PresetType } from '../../types';
const props = defineProps<{
presets: ExtendedPreset[];
searchQuery: string;
}>();
const emit = defineEmits<{
(e: 'apply', preset: ExtendedPreset): void;
(e: 'edit', preset: ExtendedPreset): void;
(e: 'delete', preset: ExtendedPreset): void;
(e: 'copy', preset: ExtendedPreset): void;
}>();
function getTypeIcon(type: PresetType) {
const icons: Record<string, string> = {
positive: '👍',
negative: '👎',
setting: '⚙️',
style: '🎨',
character: '👤',
scene: '🌍',
custom: '📝'
};
return icons[type] || '📝';
}
function getTypeLabel(type: PresetType) {
const labels: Record<string, string> = {
positive: '正面',
negative: '负面',
setting: '设定',
style: '风格',
character: '角色',
scene: '场景',
custom: '自定义'
};
return labels[type] || type;
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN');
}
</script>
<template>
<div class="preset-list-container">
<div v-if="presets.length === 0" class="empty-state">
<div class="empty-icon">📭</div>
<p class="empty-text">暂无预设</p>
</div>
<div v-else class="preset-grid">
<div v-for="preset in presets" :key="preset.id" class="preset-card">
<div class="card-header">
<div class="preset-type" :title="getTypeLabel(preset.type)">
{{ getTypeIcon(preset.type) }}
</div>
<h4 class="preset-name" :title="preset.name">{{ preset.name }}</h4>
<div class="preset-actions">
<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">
<polyline points="20,6 9,17 4,12"/>
</svg>
</button>
<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">
<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>
<div class="dropdown-menu">
<button class="action-btn more-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"/>
<circle cx="19" cy="12" r="1"/>
<circle cx="5" cy="12" r="1"/>
</svg>
</button>
<div class="dropdown-content">
<button @click="emit('edit', preset)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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 @click="emit('delete', preset)" class="delete-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="preset-preview">{{ preset.content }}</div>
<div v-if="preset.description" class="preset-desc">{{ preset.description }}</div>
</div>
<div class="card-footer">
<div class="tags-list" v-if="preset.tags && preset.tags.length">
<span v-for="tag in preset.tags.slice(0, 3)" :key="tag" class="tag">{{ tag }}</span>
<span v-if="preset.tags.length > 3" class="tag-more">+{{ preset.tags.length - 3 }}</span>
</div>
<div class="date-info" v-else>
{{ formatDate(preset.updatedAt) }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.preset-list-container {
padding: 1rem;
height: 100%;
overflow-y: auto;
scrollbar-gutter: stable;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-tertiary);
opacity: 0.6;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.preset-card {
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1rem;
display: flex;
flex-direction: column;
transition: all 0.2s ease;
position: relative;
}
.preset-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--color-border-hover);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
.preset-type {
font-size: 1.25rem;
margin-right: 0.75rem;
flex-shrink: 0;
}
.preset-name {
flex: 1;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preset-actions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.preset-card:hover .preset-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: 1px solid var(--color-border);
background-color: var(--color-bg-secondary);
color: var(--color-text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.apply-btn:hover {
background-color: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
/* Dropdown menu implementation */
.dropdown-menu {
position: relative;
height: 1.75rem; /* Match button height to ensure alignment */
}
.dropdown-menu::after {
content: '';
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: 0.5rem; /* Bridge the gap */
}
.dropdown-content {
display: none;
position: absolute;
right: 0;
top: calc(100% + 0.25rem);
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 120px;
z-index: 10;
padding: 0.25rem;
}
.dropdown-menu:hover .dropdown-content {
display: block;
}
.dropdown-content button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem;
border: none;
background: transparent;
color: var(--color-text-primary);
font-size: 0.875rem;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
}
.dropdown-content button:hover {
background-color: var(--color-bg-secondary);
}
.dropdown-content button.delete-item:hover {
color: var(--color-error);
background-color: rgba(239, 68, 68, 0.1);
}
.card-body {
flex: 1;
margin-bottom: 0.75rem;
min-height: 4rem;
}
.preset-preview {
font-family: monospace;
font-size: 0.8125rem;
color: var(--color-text-secondary);
background-color: var(--color-bg-tertiary);
padding: 0.5rem;
border-radius: var(--radius-md);
height: 3.5rem;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
margin-bottom: 0.5rem;
word-break: break-all;
}
.preset-desc {
font-size: 0.75rem;
color: var(--color-text-tertiary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.tags-list {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tag {
background-color: var(--color-bg-secondary);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.tag-more {
background-color: var(--color-bg-tertiary);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
}
</style>
+219
View File
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import FolderTreeItem from './FolderTreeItem.vue';
import type { PresetFolder } from '../../types';
interface FolderNode extends PresetFolder {
children: FolderNode[];
presetCount: number;
}
const props = defineProps<{
folderTree: FolderNode[];
selectedFolderId: string | null;
expandedIds: Set<string>;
allCount: number;
uncategorizedCount: number;
}>();
const emit = defineEmits<{
(e: 'update:selectedFolderId', id: string | null): void;
(e: 'toggle-expand', id: string): void;
(e: 'create-folder'): void;
(e: 'create-sub-folder', parentId: string): void;
(e: 'edit-folder', folder: PresetFolder): void;
(e: 'delete-folder', folder: PresetFolder): void;
}>();
function selectFolder(id: string | null) {
emit('update:selectedFolderId', id);
}
</script>
<template>
<div class="preset-sidebar">
<div class="sidebar-header">
<h3 class="sidebar-title">预设文件夹</h3>
<button @click="emit('create-folder')" class="add-folder-btn" title="新建根文件夹">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="11" x2="12" y2="17" stroke="currentColor" stroke-width="2"/>
<line x1="9" y1="14" x2="15" y2="14" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
<div class="sidebar-content">
<!-- 固定选项 -->
<div class="system-folders">
<div
class="sidebar-item"
:class="{ active: selectedFolderId === null }"
@click="selectFolder(null)"
>
<span class="item-icon">📋</span>
<span class="item-name">所有预设</span>
<span class="item-count">{{ allCount }}</span>
</div>
<div
class="sidebar-item"
:class="{ active: selectedFolderId === '' }"
@click="selectFolder('')"
>
<span class="item-icon">📂</span>
<span class="item-name">未分类</span>
<span class="item-count">{{ uncategorizedCount }}</span>
</div>
</div>
<div class="folder-tree-container">
<div v-if="folderTree.length === 0" class="empty-tree">
暂无文件夹
</div>
<FolderTreeItem
v-for="folder in folderTree"
:key="folder.id"
:folder="folder"
:level="0"
:selected-folder-id="selectedFolderId"
:expanded-ids="expandedIds"
@select="selectFolder"
@toggle="emit('toggle-expand', $event)"
@create-sub="emit('create-sub-folder', $event)"
@edit="emit('edit-folder', $event)"
@delete="emit('delete-folder', $event)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.preset-sidebar {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--color-border);
}
.sidebar-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.add-folder-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: none;
background: transparent;
color: var(--color-text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
}
.add-folder-btn:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-accent);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.system-folders {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
}
.sidebar-item {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
transition: all 0.1s ease;
margin-bottom: 0.25rem;
}
.sidebar-item:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.sidebar-item.active {
background-color: var(--color-accent);
color: white;
}
.sidebar-item.active .item-count {
background-color: rgba(255, 255, 255, 0.2);
color: white;
}
.item-icon {
margin-right: 0.75rem;
font-size: 1rem;
}
.item-name {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
}
.item-count {
font-size: 0.75rem;
background-color: var(--color-bg-tertiary);
padding: 0.125rem 0.375rem;
border-radius: 99px;
min-width: 1.25rem;
text-align: center;
}
.empty-tree {
padding: 1rem;
text-align: center;
color: var(--color-text-tertiary);
font-size: 0.875rem;
font-style: italic;
}
/* 滚动条样式优化 */
.sidebar-content::-webkit-scrollbar {
width: 6px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 3px;
}
.sidebar-content::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text-tertiary);
}
</style>
+2 -15
View File
@@ -24,26 +24,13 @@ a:hover {
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
height: 100%;
}
@media (prefers-color-scheme: light) {