初始化

This commit is contained in:
2025-11-11 07:17:46 +08:00
commit 7fd7398aaa
23 changed files with 17340 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import PromptEditor from './components/PromptEditor.vue'
import PromptManager from './components/PromptManager.vue'
const currentView = ref<'editor' | 'manager'>('editor')
const isDark = ref(false)
onMounted(() => {
// 检测系统主题偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
isDark.value = localStorage.getItem('theme') === 'dark' || (localStorage.getItem('theme') === null && prefersDark)
updateTheme()
})
function toggleTheme() {
isDark.value = !isDark.value
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
updateTheme()
}
function updateTheme() {
document.documentElement.classList.toggle('dark', isDark.value)
}
function switchView(view: 'editor' | 'manager') {
currentView.value = view
}
</script>
<template>
<div class="app-container" :class="{ dark: isDark }">
<!-- 顶部导航栏 -->
<header class="app-header">
<div class="header-content">
<div class="header-left">
<div class="app-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
</svg>
<span class="app-title">提示词编辑器</span>
</div>
</div>
<nav class="header-nav">
<button
class="nav-btn"
:class="{ active: currentView === 'editor' }"
@click="switchView('editor')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
编辑器
</button>
<button
class="nav-btn"
:class="{ active: currentView === 'manager' }"
@click="switchView('manager')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3h18v18H3zM9 9h6v6H9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
词库管理
</button>
</nav>
<div class="header-right">
<button class="theme-toggle" @click="toggleTheme" title="切换主题">
<svg v-if="!isDark" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="5" stroke="currentColor" stroke-width="2"/>
<path d="m12 1 0 2m0 18 0 2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12l2 0m18 0 2 0M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
</header>
<!-- 主要内容区域 -->
<main class="app-main">
<Transition name="view-transition" mode="out-in">
<PromptEditor v-if="currentView === 'editor'" key="editor" />
<PromptManager v-else key="manager" />
</Transition>
</main>
</div>
</template>
<style>
/* 全局样式重置和变量定义 */
:root {
/* 亮色主题 */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f8fafc;
--color-bg-tertiary: #f1f5f9;
--color-border: #e2e8f0;
--color-border-hover: #cbd5e1;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-tertiary: #64748b;
--color-accent: #3b82f6;
--color-accent-hover: #2563eb;
--color-accent-light: #dbeafe;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
}
.dark {
/* 暗色主题 */
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-bg-tertiary: #334155;
--color-border: #475569;
--color-border-hover: #64748b;
--color-text-primary: #f8fafc;
--color-text-secondary: #cbd5e1;
--color-text-tertiary: #94a3b8;
--color-accent: #60a5fa;
--color-accent-hover: #3b82f6;
--color-accent-light: #1e3a8a;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
transition: color 0.3s ease, background-color 0.3s ease;
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--color-bg-primary);
}
/* 顶部导航栏样式 */
.app-header {
background-color: var(--color-bg-primary);
border-bottom: 1px solid var(--color-border);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 1rem;
height: 3.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
}
.app-logo {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-accent);
font-weight: 600;
}
.app-title {
font-size: 1.125rem;
color: var(--color-text-primary);
}
.header-nav {
display: flex;
gap: 0.25rem;
background-color: var(--color-bg-secondary);
padding: 0.25rem;
border-radius: var(--radius-lg);
}
.nav-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: var(--color-text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
}
.nav-btn:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.nav-btn.active {
background-color: var(--color-accent);
color: white;
box-shadow: var(--shadow-sm);
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border: none;
background-color: var(--color-bg-secondary);
color: var(--color-text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
}
.theme-toggle:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
/* 主要内容区域 */
.app-main {
flex: 1;
overflow: hidden;
}
/* 视图切换动画 */
.view-transition-enter-active,
.view-transition-leave-active {
transition: all 0.3s ease;
}
.view-transition-enter-from {
opacity: 0;
transform: translateX(20px);
}
.view-transition-leave-to {
opacity: 0;
transform: translateX(-20px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.header-content {
padding: 0 0.75rem;
}
.app-logo .app-title {
display: none;
}
.nav-btn {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
.nav-btn svg {
width: 14px;
height: 14px;
}
}
@media (max-width: 640px) {
.header-nav {
gap: 0.125rem;
padding: 0.125rem;
}
.nav-btn {
padding: 0.375rem 0.5rem;
}
.nav-btn span {
display: none;
}
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
interface Props {
message: string
type: 'success' | 'error' | 'info'
show: boolean
}
defineProps<Props>()
</script>
<template>
<Transition name="notification">
<div v-if="show" class="notification-toast" :class="type">
<div class="notification-content">
<svg v-if="type === 'success'" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="22,4 12,14.01 9,11.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else-if="type === 'error'" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" stroke-width="2"/>
<line x1="9" y1="9" x2="15" y2="15" 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">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="m9 12 2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>{{ message }}</span>
</div>
</div>
</Transition>
</template>
<style scoped>
.notification-toast {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
min-width: 300px;
max-width: 500px;
padding: 1rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(8px);
}
.notification-toast.success {
background-color: var(--color-success);
color: white;
}
.notification-toast.error {
background-color: var(--color-error);
color: white;
}
.notification-toast.info {
background-color: var(--color-accent);
color: white;
}
.notification-content {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
}
/* 通知动画 */
.notification-enter-active {
transition: all 0.3s ease-out;
}
.notification-leave-active {
transition: all 0.3s ease-in;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
@media (max-width: 640px) {
.notification-toast {
left: 1rem;
right: 1rem;
min-width: auto;
}
}
</style>
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue';
import { usePromptStore } from '../stores/promptStore';
import type { LangCode, PromptGroup, PromptTag } from '../types';
const store = usePromptStore();
const draggingIndex = ref<number | null>(null);
const overIndex = ref<number | null>(null);
onMounted(() => {
store.initialize();
});
const languages = computed(() => store.languages);
const selectedLang = computed({
get: () => store.selectedLang,
set: (v: LangCode) => store.setLanguage(v),
});
const categories = computed(() => store.categories);
const currentCategory = computed(() => store.currentCategory);
const currentGroup = computed(() => store.currentGroup);
const filteredTags = computed(() => store.filteredTags);
function onDragStart(index: number) {
draggingIndex.value = index;
}
function onDragOver(index: number, e: DragEvent) {
e.preventDefault();
overIndex.value = index;
}
function onDrop(index: number) {
if (draggingIndex.value == null) return;
const from = draggingIndex.value;
const to = index;
const grpId = currentGroup.value?.id;
if (!grpId) return;
store.reorderTags(grpId, from, to);
draggingIndex.value = null;
overIndex.value = null;
}
function displayTranslation(tag: PromptTag): string {
return tag.translation?.[selectedLang.value] ?? tag.key;
}
function updateKey(tag: PromptTag, val: string) {
const gid = currentGroup.value?.id;
if (!gid) return;
store.updateTagKey(gid, tag.key, val);
}
function updateTrans(tag: PromptTag, val: string) {
const gid = currentGroup.value?.id;
if (!gid) return;
store.setTranslation(gid, tag.key, selectedLang.value, val);
}
function addTag() {
const gid = currentGroup.value?.id;
if (!gid) return;
store.addTag(gid);
}
function removeTag(tag: PromptTag) {
const gid = currentGroup.value?.id;
if (!gid) return;
store.removeTag(gid, tag.key);
}
function toggleHidden(tag: PromptTag) {
const gid = currentGroup.value?.id;
if (!gid) return;
store.toggleHidden(gid, tag.key);
}
function exportAll() {
const json = store.exportToJson();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `prompt_dataset_${new Date().toISOString().slice(0,19)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function importAll(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const text = String(reader.result);
const bundle = JSON.parse(text);
store.importFromBundle(bundle);
} catch (e) {
alert('导入失败:JSON 格式不正确');
} finally {
input.value = '';
}
};
reader.readAsText(file);
}
function resetDefault() {
store.resetToDefault();
}
</script>
<template>
<div class="pm-root">
<header class="pm-toolbar">
<div class="pm-left">
<label>语言</label>
<select v-model="selectedLang">
<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)"
/>
</div>
<div class="pm-right">
<button class="pm-btn" @click="exportAll">导出 JSON</button>
<span class="pm-tip">导出 JSON 会同时包含你的预设</span>
<label class="pm-import pm-btn">导入 JSON
<input type="file" accept="application/json" @change="importAll" />
</label>
<button class="pm-btn" @click="resetDefault">重置为内置词库</button>
</div>
</header>
<main class="pm-main">
<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)">
{{ 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)">
<span class="pm-color" :style="{ background: g.color || 'transparent' }"></span>
{{ g.name }}
</li>
</ul>
</aside>
<section class="pm-list">
<div class="pm-list-toolbar">
<button @click="addTag">新增提示词</button>
</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 }"
>
<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)" />
<button class="pm-hide" @click="toggleHidden(t)">{{ t.hidden ? '显示' : '隐藏' }}</button>
<button class="pm-del" @click="removeTag(t)">删除</button>
</li>
</ul>
</section>
</main>
</div>
</template>
<style scoped>
.pm-root { display: flex; flex-direction: column; height: 100vh; font-family: system-ui, -apple-system, Segoe UI, Roboto, 'Noto Sans', 'PingFang SC', 'Microsoft YaHei', sans-serif; }
.pm-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; gap: 12px; }
.pm-left { display: flex; align-items: center; gap: 8px; }
.pm-right { display: flex; align-items: center; gap: 8px; }
.pm-tip { font-size: 12px; color: #6b7280; }
.pm-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; line-height: 1; font-size: 14px; }
.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 #d1d5db; border-radius: 6px; }
.pm-main { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 50px); }
.pm-cats { border-right: 1px solid #e5e7eb; overflow: auto; padding: 8px; }
.pm-section-title { font-size: 12px; color: #6b7280; margin: 8px 0; }
.pm-cats ul { list-style: none; margin: 0; padding: 0; }
.pm-cats li { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.pm-cats li.active { background: #eef2ff; }
.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; margin-bottom: 8px; }
.pm-empty { color: #6b7280; 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 #e5e7eb; border-radius: 6px; }
.pm-tags li.hidden { opacity: 0.5; }
.pm-handle { cursor: grab; user-select: none; color: #6b7280; text-align: center; }
.pm-key, .pm-trans { padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 6px; }
.pm-hide, .pm-del, .pm-list-toolbar button, .pm-right button { padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: white; cursor: pointer; line-height: 1; }
.pm-hide:hover, .pm-del:hover, .pm-list-toolbar button:hover, .pm-right button:hover, .pm-btn:hover { background: #f3f4f6; }
</style>
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
+492
View File
@@ -0,0 +1,492 @@
import { defineStore } from 'pinia';
import { loadInitialDataset } from '../utils/yamlLoader';
import type { PromptDataset, PromptCategory, PromptGroup, PromptTag, LangCode, ExportBundle, CustomDiff, PromptPreset } from '../types';
const LS_KEY = 'ops.prompt.dataset.v1';
let saveTimer: number | null = null; // 非响应式计时器,避免递归更新
let baseline: PromptDataset | null = null; // 基线词库(从 public/sd 加载)
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
export const usePromptStore = defineStore('promptStore', {
state: () => ({
dataset: null as PromptDataset | null,
selectedLang: 'zh_CN' as LangCode,
selectedCategoryIndex: 0,
selectedGroupIndex: 0,
searchQuery: '',
// 编辑器相关
promptText: '',
presets: [] as PromptPreset[],
}),
getters: {
categories: (s) => s.dataset?.categories ?? [],
currentCategory: (s) => s.dataset?.categories[s.selectedCategoryIndex] ?? null,
currentGroup(): PromptGroup | null {
return this.currentCategory?.groups[this.selectedGroupIndex] ?? null;
},
languages: (s) => s.dataset?.languages ?? ['en'],
filteredTags(): PromptTag[] {
const grp = this.currentGroup;
if (!grp) return [] as PromptTag[];
const q = this.searchQuery.trim().toLowerCase();
if (!q) return grp.tags;
return grp.tags.filter((t) => {
const trans = t.translation?.[this.selectedLang] ?? '';
return (
t.key.toLowerCase().includes(q) ||
trans.toLowerCase().includes(q)
);
});
},
tokens(s): string[] {
return splitTokens(s.promptText);
},
},
actions: {
async initialize() {
// 先加载基线词库
baseline = await loadInitialDataset();
const raw = localStorage.getItem(LS_KEY);
if (raw) {
try {
const bundle = JSON.parse(raw) as ExportBundle;
if (bundle.dataset) {
this.dataset = bundle.dataset;
} else if (bundle.customDiff) {
this.dataset = this.applyDiff(deepClone(baseline!), bundle.customDiff);
} else {
this.dataset = deepClone(baseline!);
}
this.presets = bundle.presets || [];
// 恢复编辑器内容与语言
if (typeof bundle.promptText === 'string') {
this.promptText = bundle.promptText;
}
if (bundle.selectedLang) {
this.selectedLang = bundle.selectedLang as LangCode;
}
} catch {
this.dataset = deepClone(baseline!);
}
} else {
this.dataset = deepClone(baseline!);
}
// 若无恢复语言,则按数据集进行推断
if (!this.selectedLang) {
const guessLang: LangCode = (this.dataset.languages.includes('zh_CN') ? 'zh_CN' : 'en') as LangCode;
this.selectedLang = guessLang;
}
this.autoPersist();
},
autoPersist() {
// 订阅状态变化并延迟保存(不写入 store,避免递归)
this.$subscribe(() => {
if (saveTimer) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
this.save();
}, 400);
}, { detached: true });
},
save() {
if (!this.dataset) return;
const bundle: ExportBundle = {
version: 1,
savedAt: new Date().toISOString(),
dataset: deepClone(this.dataset),
presets: deepClone(this.presets),
promptText: this.promptText,
selectedLang: this.selectedLang,
};
localStorage.setItem(LS_KEY, JSON.stringify(bundle));
},
resetToDefault() {
this.importFromBundle(null);
},
async importFromBundle(bundle: ExportBundle | null) {
if (!bundle) {
if (!baseline) baseline = await loadInitialDataset();
this.dataset = deepClone(baseline!);
} else {
if (bundle.dataset) {
this.dataset = bundle.dataset;
} else if (bundle.customDiff) {
if (!baseline) baseline = await loadInitialDataset();
this.dataset = this.applyDiff(deepClone(baseline!), bundle.customDiff);
}
this.presets = bundle.presets || [];
}
this.selectedCategoryIndex = 0;
this.selectedGroupIndex = 0;
this.save();
},
exportToJson(): string {
// 导出仅包含自定义差异(不包含公共词库)
const diff = this.buildDiff(baseline!, this.dataset!);
const bundle: ExportBundle = {
version: 1,
savedAt: new Date().toISOString(),
customDiff: diff,
presets: deepClone(this.presets),
};
return JSON.stringify(bundle, null, 2);
},
setLanguage(lang: LangCode) {
this.selectedLang = lang;
},
selectCategory(idx: number) {
this.selectedCategoryIndex = idx;
this.selectedGroupIndex = 0;
},
selectGroup(idx: number) {
this.selectedGroupIndex = idx;
},
setSearch(q: string) {
this.searchQuery = q;
},
addTag(groupId: string, key = 'new_tag') {
const grp = this.findGroupById(groupId);
if (!grp) return;
grp.tags.push({ key, translation: { en: key, [this.selectedLang]: key } });
},
removeTag(groupId: string, key: string) {
const grp = this.findGroupById(groupId);
if (!grp) return;
grp.tags = grp.tags.filter((t) => t.key !== key);
},
updateTagKey(groupId: string, oldKey: string, newKey: string) {
const grp = this.findGroupById(groupId);
if (!grp) return;
const tag = grp.tags.find((t) => t.key === oldKey);
if (!tag) return;
tag.key = newKey;
if (!tag.translation) tag.translation = {};
tag.translation.en = newKey;
},
setTranslation(groupId: string, key: string, lang: LangCode, val: string) {
const grp = this.findGroupById(groupId);
if (!grp) return;
const tag = grp.tags.find((t) => t.key === key);
if (!tag) return;
if (!tag.translation) tag.translation = {};
tag.translation[lang] = val;
},
toggleHidden(groupId: string, key: string) {
const grp = this.findGroupById(groupId);
if (!grp) return;
const tag = grp.tags.find((t) => t.key === key);
if (!tag) return;
tag.hidden = !tag.hidden;
},
reorderTags(groupId: string, fromIndex: number, toIndex: number) {
const grp = this.findGroupById(groupId);
if (!grp) return;
const list = grp.tags;
if (fromIndex < 0 || toIndex < 0 || fromIndex >= list.length || toIndex >= list.length) return;
const [item] = list.splice(fromIndex, 1);
if (item) list.splice(toIndex, 0, item);
},
// —— 编辑器核心:基于左侧文本的 token 操作 ——
setPromptText(text: string) {
this.promptText = normalizePrompt(text);
},
setPromptTextRaw(text: string) {
// 原始赋值,不做格式化,保留光标与撤回体验
this.promptText = text;
},
replaceChineseComma() {
this.promptText = this.promptText.replace(//g, ',');
},
formatPrompt() {
this.promptText = normalizePrompt(this.promptText);
},
updateToken(index: number, newToken: string) {
const tokens = splitTokens(this.promptText);
if (index < 0 || index >= tokens.length) return;
tokens[index] = normalizeToken(newToken);
this.promptText = tokens.join(', ');
},
reorderTokens(fromIndex: number, toIndex: number) {
const tokens = splitTokens(this.promptText);
if (fromIndex < 0 || toIndex < 0 || fromIndex >= tokens.length || toIndex >= tokens.length) return;
const [item] = tokens.splice(fromIndex, 1);
tokens.splice(toIndex, 0, item!);
this.promptText = tokens.join(', ');
},
removeToken(index: number) {
const tokens = splitTokens(this.promptText);
if (index < 0 || index >= tokens.length) return;
tokens.splice(index, 1);
this.promptText = tokens.join(', ');
},
addTokenAfter(index: number, token: string) {
const tokens = splitTokens(this.promptText);
tokens.splice(index + 1, 0, normalizeToken(token));
this.promptText = tokens.join(', ');
},
getTagByKey(key: string): PromptTag | null {
for (const cat of this.dataset?.categories || []) {
for (const g of cat.groups) {
const t = g.tags.find((x) => x.key === key);
if (t) return t;
}
}
return null;
},
getTranslation(key: string, lang: LangCode): string | null {
const tag = this.getTagByKey(key);
if (!tag) return null;
return tag.translation?.[lang] ?? tag.key;
},
getSuggestions(prefix: string, limit = 8): string[] {
const list: string[] = [];
const seen = new Set<string>();
const p = prefix.trim().toLowerCase();
if (!p) return [];
for (const cat of this.dataset?.categories || []) {
for (const g of cat.groups) {
for (const t of g.tags) {
const k = t.key;
if (seen.has(k)) continue;
if (k.toLowerCase().includes(p)) {
list.push(k);
seen.add(k);
if (list.length >= limit) return list;
}
}
}
}
return list;
},
addMapping(key: string, lang: LangCode, val: string) {
// 若 key 已存在则更新翻译;否则在自定义分组增加映射
const exist = this.getTagByKey(key);
if (exist) {
if (!exist.translation) exist.translation = {};
exist.translation[lang] = val;
return;
}
const grp = this.ensureCustomGroup();
grp.tags.push({ key, translation: { en: key, [lang]: val } });
},
ensureCustomGroup(): PromptGroup {
const catName = 'Custom';
let cat = this.dataset?.categories.find((c) => c.name === catName);
if (!cat) {
cat = { id: `cat_${Math.random().toString(36).slice(2, 9)}`, name: catName, groups: [] };
this.dataset?.categories.push(cat);
}
let grp = cat.groups.find((g) => g.name === 'User Mapping');
if (!grp) {
grp = { id: `grp_${Math.random().toString(36).slice(2, 9)}`, name: 'User Mapping', color: '#6366f1', tags: [] };
cat.groups.push(grp);
}
return grp;
},
savePreset(name: string) {
const now = new Date().toISOString();
const exist = this.presets.find((p) => p.name === name);
if (exist) {
exist.text = this.promptText;
exist.updatedAt = now;
} else {
this.presets.push({ name, text: this.promptText, updatedAt: now });
}
this.save();
},
loadPreset(name: string) {
const p = this.presets.find((x) => x.name === name);
if (!p) return;
this.promptText = p.text;
},
deletePreset(name: string) {
this.presets = this.presets.filter((p) => p.name !== name);
this.save();
},
renamePreset(oldName: string, newName: string) {
const target = this.presets.find((p) => p.name === oldName);
if (!target) return;
const conflict = this.presets.find((p) => p.name === newName);
const now = new Date().toISOString();
if (conflict && conflict !== target) {
// 合并到冲突项:用新名覆盖旧名文本
conflict.text = target.text;
conflict.updatedAt = now;
this.deletePreset(oldName);
} else {
target.name = newName;
target.updatedAt = now;
}
this.save();
},
findGroupById(id: string): PromptGroup | null {
for (const cat of this.dataset?.categories || []) {
const grp = cat.groups.find((g) => g.id === id);
if (grp) return grp;
}
return null;
},
// 工具方法:构建差异与应用差异
buildDiff(base: PromptDataset, cur: PromptDataset): CustomDiff {
const categories: CustomDiff['categories'] = [];
const baseCatMap = new Map(base.categories.map((c) => [c.name, c]));
const curCatMap = new Map(cur.categories.map((c) => [c.name, c]));
for (const [name, curCat] of curCatMap.entries()) {
const baseCat = baseCatMap.get(name);
const catDiff: CustomDiff['categories'][number] = { name };
if (!baseCat) {
catDiff.addedGroups = curCat.groups.map((g) => deepClone(g));
} else {
const baseGrpMap = new Map(baseCat.groups.map((g) => [g.name, g]));
const curGrpMap = new Map(curCat.groups.map((g) => [g.name, g]));
const groupsDiff: NonNullable<typeof catDiff.groups> = [];
for (const [gname, curGrp] of curGrpMap.entries()) {
const baseGrp = baseGrpMap.get(gname);
if (!baseGrp) {
if (!catDiff.addedGroups) catDiff.addedGroups = [];
catDiff.addedGroups.push(deepClone(curGrp));
continue;
}
const updated: { name: string; color?: string; added?: PromptTag[]; removed?: string[]; updated?: Array<{ key: string; translation?: Partial<Record<LangCode, string>>; hidden?: boolean }>; order?: string[] } = { name: gname } as any;
if ((curGrp.color || '') !== (baseGrp.color || '')) updated.color = curGrp.color;
const baseTagMap = new Map(baseGrp.tags.map((t) => [t.key, t]));
const curTagMap = new Map(curGrp.tags.map((t) => [t.key, t]));
const addList: PromptTag[] = [];
const updList: Array<{ key: string; translation?: Partial<Record<LangCode, string>>; hidden?: boolean }> = [];
for (const [key, curTag] of curTagMap.entries()) {
const baseTag = baseTagMap.get(key);
if (!baseTag) {
addList.push(deepClone(curTag));
} else {
let changed = false;
const change: { key: string; translation?: Partial<Record<LangCode, string>>; hidden?: boolean } = { key };
for (const l of ['en', 'zh_CN', 'es_ES'] as LangCode[]) {
const a = baseTag.translation?.[l] ?? '';
const b = curTag.translation?.[l] ?? '';
if (a !== b) {
if (!change.translation) change.translation = {};
change.translation[l] = b;
changed = true;
}
}
const aHidden = !!baseTag.hidden;
const bHidden = !!curTag.hidden;
if (aHidden !== bHidden) { change.hidden = bHidden; changed = true; }
if (changed) updList.push(change);
}
}
const remList: string[] = [];
for (const [key] of baseTagMap.entries()) {
if (!curTagMap.has(key)) remList.push(key);
}
const baseOrder = baseGrp.tags.map((t) => t.key);
const curOrder = curGrp.tags.map((t) => t.key);
const orderChanged = baseOrder.length !== curOrder.length || baseOrder.some((k, i) => k !== curOrder[i]);
if (updated.color || addList.length || remList.length || updList.length || orderChanged) {
if (updated.color) updated.color = updated.color;
if (addList.length) updated.added = addList;
if (remList.length) updated.removed = remList;
if (updList.length) updated.updated = updList;
if (orderChanged) updated.order = curOrder;
groupsDiff.push(updated as any);
}
}
const removedGroups = baseCat.groups.filter((bg) => !curGrpMap.has(bg.name)).map((g) => g.name);
if (removedGroups.length) catDiff.removedGroups = removedGroups;
if (groupsDiff.length) catDiff.groups = groupsDiff;
}
if (catDiff.addedGroups?.length || catDiff.removedGroups?.length || catDiff.groups?.length) {
categories.push(catDiff);
}
}
for (const baseCat of base.categories) {
if (!curCatMap.has(baseCat.name)) {
categories.push({ name: baseCat.name, removedGroups: baseCat.groups.map((g) => g.name) });
}
}
return { categories };
},
applyDiff(target: PromptDataset, diff: CustomDiff): PromptDataset {
const catMap = new Map(target.categories.map((c) => [c.name, c]));
for (const c of diff.categories || []) {
let cat = catMap.get(c.name);
if (!cat) {
cat = { id: `cat_${Math.random().toString(36).slice(2, 9)}`, name: c.name, groups: [] };
target.categories.push(cat);
catMap.set(c.name, cat);
}
const grpMap = new Map(cat.groups.map((g) => [g.name, g]));
for (const g of c.addedGroups || []) {
const copy = deepClone(g);
// 保持现有结构:如果没有 id,生成一个
(copy as PromptGroup).id = `grp_${Math.random().toString(36).slice(2, 9)}`;
(copy as PromptGroup).id = `grp_${Math.random().toString(36).slice(2, 9)}`;
cat.groups.push(copy as PromptGroup);
grpMap.set(copy.name, copy as PromptGroup);
}
for (const gname of c.removedGroups || []) {
cat.groups = cat.groups.filter((g) => g.name !== gname);
grpMap.delete(gname);
}
for (const gdiff of c.groups || []) {
let grp = grpMap.get(gdiff.name);
if (!grp) {
grp = { id: `grp_${Math.random().toString(36).slice(2, 9)}`, name: gdiff.name, color: gdiff.color, tags: [] };
cat.groups.push(grp);
grpMap.set(grp.name, grp);
}
if (gdiff.color !== undefined) grp.color = gdiff.color;
const tagMap = new Map(grp.tags.map((t) => [t.key, t]));
for (const t of gdiff.added || []) {
const copy = deepClone(t);
grp.tags.push(copy);
tagMap.set(copy.key, copy);
}
for (const key of gdiff.removed || []) {
grp.tags = grp.tags.filter((t) => t.key !== key);
tagMap.delete(key);
}
for (const u of gdiff.updated || []) {
const tag = tagMap.get(u.key);
if (!tag) continue;
if (u.translation) tag.translation = { ...(tag.translation || {}), ...u.translation };
if (u.hidden !== undefined) tag.hidden = u.hidden;
}
if (gdiff.order && gdiff.order.length) {
const keyToTag = new Map(grp.tags.map((t) => [t.key, t]));
const reordered: PromptTag[] = [];
for (const key of gdiff.order) {
const tag = keyToTag.get(key);
if (tag) reordered.push(tag);
}
// 保留未列出的条目
for (const t of grp.tags) {
if (!gdiff.order.includes(t.key)) reordered.push(t);
}
grp.tags = reordered;
}
}
}
return target;
},
},
});
// —— 工具方法 ——
function splitTokens(text: string): string[] {
return text
.split(/[,]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
function normalizeToken(t: string): string {
return t.trim();
}
function normalizePrompt(text: string): string {
return splitTokens(text).join(', ');
}
+60
View File
@@ -0,0 +1,60 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
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;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
+67
View File
@@ -0,0 +1,67 @@
export type LangCode = 'en' | 'zh_CN' | 'es_ES';
export interface PromptTag {
key: string;
translation?: Partial<Record<LangCode, string>>;
hidden?: boolean;
}
export interface PromptGroup {
id: string;
name: string;
color?: string;
tags: PromptTag[];
}
export interface PromptCategory {
id: string;
name: string;
groups: PromptGroup[];
}
export interface PromptDataset {
categories: PromptCategory[];
languages: LangCode[];
updatedAt?: string;
}
export interface ExportBundle {
version: number;
savedAt: string;
dataset?: PromptDataset; // full snapshot (used for localStorage persistence)
customDiff?: CustomDiff; // only user-defined changes for export/import
presets?: PromptPreset[]; // saved prompt texts by name
// editor state persistence
promptText?: string;
selectedLang?: LangCode;
}
export interface CustomDiff {
categories: Array<{
name: string;
addedGroups?: Array<{
name: string;
color?: string;
tags: PromptTag[];
}>;
removedGroups?: string[];
groups?: Array<{
name: string;
color?: string;
added?: PromptTag[];
removed?: string[]; // tag keys
updated?: Array<{
key: string;
translation?: Partial<Record<LangCode, string>>;
hidden?: boolean;
}>;
order?: string[]; // tag keys in desired order
}>;
}>;
}
export interface PromptPreset {
name: string;
text: string;
updatedAt: string;
}
+106
View File
@@ -0,0 +1,106 @@
import YAML from 'yaml';
import type { PromptDataset, PromptCategory, PromptGroup, PromptTag, LangCode } from '../types';
type YamlGroup = {
name: string;
color?: string;
tags: Record<string, string | null>;
};
type YamlCategory = {
name: string;
groups: YamlGroup[];
};
async function fetchYaml(path: string): Promise<YamlCategory[]> {
const res = await fetch(path);
if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.status}`);
const text = await res.text();
const parsed = YAML.parse(text) as YamlCategory[];
return parsed;
}
function uid(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
}
function collectTranslations(root: YamlCategory[]): Record<string, string> {
const map: Record<string, string> = {};
for (const cat of root) {
for (const grp of cat.groups || []) {
const entries = grp.tags || {};
for (const [key, val] of Object.entries(entries)) {
if (key && typeof key === 'string') {
const value = val == null ? '' : String(val);
map[key] = value;
}
}
}
}
return map;
}
function buildBaseStructure(defaultYaml: YamlCategory[]): PromptCategory[] {
const categories: PromptCategory[] = [];
for (const cat of defaultYaml) {
const groups: PromptGroup[] = [];
for (const grp of cat.groups || []) {
const tags: PromptTag[] = [];
const entries = grp.tags || {};
for (const key of Object.keys(entries)) {
tags.push({ key, translation: { en: key } });
}
groups.push({ id: uid('grp'), name: grp.name, color: grp.color, tags });
}
categories.push({ id: uid('cat'), name: cat.name, groups });
}
return categories;
}
function mergeLanguage(dataset: PromptDataset, translations: Record<string, string>, lang: LangCode) {
for (const cat of dataset.categories) {
for (const grp of cat.groups) {
for (const tag of grp.tags) {
const t = translations[tag.key];
if (typeof t === 'string') {
if (!tag.translation) tag.translation = {};
tag.translation[lang] = t || tag.key;
}
}
}
}
}
export async function loadInitialDataset(): Promise<PromptDataset> {
// Base: default.yaml defines structure and keys
const defaultYaml = await fetchYaml('/sd/default.yaml');
const baseCategories = buildBaseStructure(defaultYaml);
const dataset: PromptDataset = {
categories: baseCategories,
languages: ['en'],
updatedAt: new Date().toISOString(),
};
// zh_CN and es_ES translations are optional
try {
const zhRoot = await fetchYaml('/sd/zh_CN.yaml');
const zhMap = collectTranslations(zhRoot);
mergeLanguage(dataset, zhMap, 'zh_CN');
if (!dataset.languages.includes('zh_CN')) dataset.languages.push('zh_CN');
} catch (err) {
// ignore if missing
console.warn('zh_CN.yaml not found or invalid', err);
}
try {
const esRoot = await fetchYaml('/sd/es_ES.yaml');
const esMap = collectTranslations(esRoot);
mergeLanguage(dataset, esMap, 'es_ES');
if (!dataset.languages.includes('es_ES')) dataset.languages.push('es_ES');
} catch (err) {
// ignore if missing
console.warn('es_ES.yaml not found or invalid', err);
}
return dataset;
}