性能优化和样式更新

This commit is contained in:
2026-06-23 10:08:39 +08:00
parent c3739b18eb
commit 58260797b2
4 changed files with 163 additions and 47 deletions
+7 -1
View File
@@ -45,6 +45,8 @@ const selectedFolderId = computed({
// 保存预设时是否同时标记为收藏
const saveAsFavorite = ref(false);
const viewMode = ref<'compact' | 'detail'>('compact');
// 左侧光标所在的 token 序号,用于在右侧映射中定位高亮
const activeTokenIndex = ref<number | null>(null);
const showPresetDropdown = ref(false);
const showTranslationPopup = ref(false);
const translationTargetToken = ref<string | null>(null);
@@ -829,6 +831,7 @@ const unmappedTokens = computed(() => {
});
function isUnmapped(key: string): boolean {
void store.mappingVersion; // 追踪映射变更以触发响应式刷新
const { core } = parseDetailedToken(key);
const tag = store.getTagByKey(core);
return !tag || !tag.translation?.[selectedLang.value];
@@ -842,6 +845,7 @@ function handleApplyTranslation(results: { key: string; trans: string }[]) {
}
function displayTrans(key: string): string {
void store.mappingVersion; // 追踪映射变更以触发响应式刷新
const { core, weight, wrappers, prefix, suffix } = parseDetailedToken(key);
const tag = store.getTagByKey(core);
const translatedCore = tag?.translation?.[selectedLang.value] ?? tag?.key ?? core;
@@ -870,11 +874,13 @@ function isRemoveDisabled(token: string): boolean {
:get-suggestions="(prefix, limit) => store.getSuggestions(prefix, limit)"
@update-suggestions="updateSuggestionsFromText" @copy="copyLeft" @replace-cn-comma="replaceCnComma"
@format-prompt="formatPrompt" @unify-priority="unifyPriorityStyle" @toggle-underscore="toggleUnderscoreSpace"
@add-tag="handleAddTag" @drag-tag-start="handleQuickAddDragStart" @drag-tag-end="handleQuickAddDragEnd" />
@add-tag="handleAddTag" @drag-tag-start="handleQuickAddDragStart" @drag-tag-end="handleQuickAddDragEnd"
@locate-token="(i) => activeTokenIndex = i >= 0 ? i : null" />
<TokenMappingPanel ref="tokenMappingRef" :tokens="tokens" :selected-lang="selectedLang"
v-model:view-mode="viewMode" :dragging-index="draggingIndex" :over-index="overIndex" :insert-side="insertSide"
:is-dragging="isDragging" :edit-suggestions="editSuggestions" :numeric-mode="numericMode"
:active-index="activeTokenIndex"
:display-trans="displayTrans" :is-unmapped="isUnmapped" :get-token-wrapper-info="getTokenWrapperInfo"
:has-weight-suffix="hasWeightSuffix" :get-suggestions="(prefix, limit) => store.getSuggestions(prefix, limit)"
@pointer-down="onPointerDown" @panel-dragover="handlePanelDragOver" @panel-dragleave="handlePanelDragLeave"
+38 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
import { splitTokens, parseDetailedToken, constructToken } from '../../stores/promptStore';
import { splitTokens, parseDetailedToken, constructToken, normalizeSymbols } from '../../stores/promptStore';
import PromptQuickAdd from '../PromptQuickAdd.vue';
const props = defineProps<{
@@ -26,6 +26,7 @@ const emit = defineEmits<{
'add-tag': [tag: string];
'drag-tag-start': [tag: string];
'drag-tag-end': [];
'locate-token': [index: number];
}>();
const inputEl = ref<HTMLTextAreaElement | null>(null);
@@ -154,9 +155,41 @@ async function applySuggestion(s: string) {
emit('update-suggestions');
}
function updateSuggestions() {
// 通知父组件更新建议
// 计算与 splitTokens 对齐的非空 token 区间(用于光标定位)
function computeTokenRanges(txt: string): { start: number; end: number }[] {
const ranges: { start: number; end: number }[] = [];
let depth = 0;
let segStart = 0;
for (let i = 0; i < txt.length; i++) {
const c = txt[i]!;
if (c === '(' || c === '[' || c === '{' || c === '<') depth++;
else if (c === ')' || c === ']' || c === '}' || c === '>') depth = Math.max(0, depth - 1);
if ((c === ',' || c === '\n') && depth === 0) {
ranges.push({ start: segStart, end: i });
segStart = i + 1;
}
}
ranges.push({ start: segStart, end: txt.length });
return ranges.filter(r => txt.slice(r.start, r.end).trim().length > 0);
}
// 根据光标位置定位对应的 token 序号,通知父组件高亮右侧映射
function emitLocate() {
const el = inputEl.value;
if (!el) return;
const norm = normalizeSymbols(props.text);
const pos = el.selectionStart ?? norm.length;
const ranges = computeTokenRanges(norm);
if (!ranges.length) { emit('locate-token', -1); return; }
let idx = ranges.findIndex(r => pos >= r.start && pos <= r.end);
if (idx === -1) idx = ranges.findIndex(r => pos <= r.end);
if (idx === -1) idx = ranges.length - 1;
emit('locate-token', idx);
}
function onCursorActivity() {
emit('update-suggestions');
emitLocate();
}
defineExpose({
@@ -172,8 +205,8 @@ defineExpose({
class="pe-input"
v-model="localText"
@keydown="onKeyDown"
@click="updateSuggestions"
@keyup="updateSuggestions"
@click="onCursorActivity"
@keyup="onCursorActivity"
placeholder="例如:1girl, aaa, bbb, ccc"
></textarea>
<div class="pe-input-actions">
+114 -41
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
import { ref, computed, nextTick, watch } from 'vue';
import { parseDetailedToken, constructToken } from '../../stores/promptStore';
import type { LangCode } from '../../types';
@@ -13,6 +13,7 @@ const props = defineProps<{
isDragging: boolean;
editSuggestions: string[];
numericMode: boolean;
activeIndex: number | null;
displayTrans: (key: string) => string;
isUnmapped: (key: string) => boolean;
getTokenWrapperInfo: (token: string) => { wrapperCount: number };
@@ -54,6 +55,31 @@ const localViewMode = computed({
set: (v: 'compact' | 'detail') => emit('update:viewMode', v),
});
// 每个词条的展示数据只计算一次,避免模板中重复调用 displayTrans / isUnmapped
const rows = computed(() => props.tokens.map((key) => ({
key,
trans: props.displayTrans(key),
unmapped: props.isUnmapped(key),
removeDisabled: isRemoveDisabled(key),
})));
// 左侧点击提示词 → 右侧映射定位并平滑滚动到视图中央(仅在出现滚动条时滚动)
watch(() => props.activeIndex, (idx) => {
if (idx == null || idx < 0) return;
nextTick(() => {
const container = dragContainer.value;
if (!container) return;
const el = container.querySelector<HTMLElement>(`[data-index="${idx}"]`);
if (!el) return;
const scroller = container.closest('.pe-right-pane') as HTMLElement | null;
if (!scroller || scroller.scrollHeight <= scroller.clientHeight) return;
const erect = el.getBoundingClientRect();
const srect = scroller.getBoundingClientRect();
const delta = (erect.top - srect.top) - (srect.height / 2 - erect.height / 2);
scroller.scrollBy({ top: delta, behavior: 'smooth' });
});
});
function beginEdit(i: number) {
editingIndex.value = i;
editingValue.value = props.tokens[i] ?? '';
@@ -204,33 +230,49 @@ defineExpose({
<!-- 精简视图 -->
<div class="pe-tokens-compact" v-if="viewMode === 'compact'">
<div
v-for="(k,i) in tokens"
:key="k + '_' + i"
v-for="(row,i) in rows"
:key="row.key + '_' + i"
:data-index="i"
:class="{
'dragging': draggingIndex === i,
'insert-before': overIndex === i && insertSide === 'before' && draggingIndex !== i,
'insert-after': overIndex === i && insertSide === 'after' && draggingIndex !== i,
'editing': editingIndex === i
'editing': editingIndex === i,
'is-active': activeIndex === i && draggingIndex !== i
}"
class="pe-token-compact"
@pointerdown="emit('pointer-down', i, $event)"
@dblclick="beginEdit(i)"
:title="`${k} → ${displayTrans(k)}`"
:title="`${row.key} → ${row.trans}`"
>
<span class="pe-handle-compact"></span>
<div v-if="editingIndex === i" class="pe-edit-inline">
<input
ref="editEl"
class="pe-edit-input"
v-model="editingValue"
@keydown="onEditKeyDown"
@keydown.enter.stop.prevent="commitEdit"
@keydown.esc.stop.prevent="cancelEdit"
@click="updateEditSuggestions"
@keyup="updateEditSuggestions"
placeholder="编辑提示词"
/>
<div class="pe-edit-row">
<input
ref="editEl"
class="pe-edit-input"
v-model="editingValue"
@keydown="onEditKeyDown"
@keydown.enter.stop.prevent="commitEdit"
@keydown.esc.stop.prevent="cancelEdit"
@click="updateEditSuggestions"
@keyup="updateEditSuggestions"
placeholder="编辑提示词"
/>
<div class="pe-edit-actions">
<button @click="commitEdit" class="pe-edit-save-btn" title="保存">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="20 6 9 17 4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button @click="cancelEdit" class="pe-edit-cancel-btn" title="取消">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
</div>
<ul class="pe-edit-suggest" v-if="editSuggestions.length">
<li
v-for="s in editSuggestions"
@@ -239,25 +281,12 @@ defineExpose({
@click="applyEditSuggestion(s)"
>{{ s }}</li>
</ul>
<div class="pe-edit-actions">
<button @click="commitEdit" class="pe-edit-save-btn" title="保存">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="20 6 9 17 4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button @click="cancelEdit" class="pe-edit-cancel-btn" title="取消">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
</div>
<div v-else class="pe-token-content">
<span class="pe-key-compact">{{ k }}</span>
<span class="pe-key-compact">{{ row.key }}</span>
<span class="pe-arrow-compact"></span>
<span class="pe-trans-compact" :class="{ unmapped: isUnmapped(k) }">
{{ displayTrans(k) }}
<span class="pe-trans-compact" :class="{ unmapped: row.unmapped }">
{{ row.trans }}
</span>
</div>
<div class="pe-token-controls-compact">
@@ -272,7 +301,7 @@ defineExpose({
@click="emit('remove-wrapper', i)"
class="pe-remove-wrapper-btn"
:title="numericMode ? '降低权重' : '移除优先级'"
:disabled="isRemoveDisabled(k)"
:disabled="row.removeDisabled"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
@@ -292,14 +321,15 @@ defineExpose({
<!-- 详细视图 -->
<div class="pe-tokens-detail" v-else>
<div
v-for="(k,i) in tokens"
:key="k + '_' + i"
v-for="(row,i) in rows"
:key="row.key + '_' + i"
:data-index="i"
:class="{
'dragging': draggingIndex === i,
'insert-before': overIndex === i && insertSide === 'before' && draggingIndex !== i,
'insert-after': overIndex === i && insertSide === 'after' && draggingIndex !== i,
'editing': editingIndex === i
'editing': editingIndex === i,
'is-active': activeIndex === i && draggingIndex !== i
}"
class="pe-token-detail"
@pointerdown="emit('pointer-down', i, $event)"
@@ -307,12 +337,12 @@ defineExpose({
<div class="pe-token-header">
<span class="pe-handle-detail"></span>
<div class="pe-token-main" @dblclick="beginEdit(i)">
<span class="pe-key-detail">{{ k }}</span>
<span class="pe-key-detail">{{ row.key }}</span>
<span class="pe-arrow-detail"></span>
<span class="pe-trans-detail" :class="{ unmapped: isUnmapped(k) }">{{ displayTrans(k) }}</span>
<span class="pe-trans-detail" :class="{ unmapped: row.unmapped }">{{ row.trans }}</span>
</div>
<div class="pe-token-controls">
<button v-if="isUnmapped(k)" class="pe-add-map-btn" @click="emit('show-add-map', i)" title="添加映射">
<button v-if="row.unmapped" class="pe-add-map-btn" @click="emit('show-add-map', i)" title="添加映射">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
@@ -329,7 +359,7 @@ defineExpose({
@click="emit('remove-wrapper', i)"
class="pe-remove-wrapper-detail-btn"
:title="numericMode ? '降低权重' : '移除优先级'"
:disabled="isRemoveDisabled(k)"
:disabled="row.removeDisabled"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 3h3v3M8 3H5v3m0 12v3h3m8 0h3v-3" stroke="currentColor" stroke-width="2" fill="none"/>
@@ -489,6 +519,10 @@ defineExpose({
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
background-color: var(--color-bg-primary);
/* 编辑时整行展开,给输入框与候选词留足空间,避免被压缩 */
width: 100%;
flex-basis: 100%;
align-items: stretch;
}
.pe-token-compact.editing .pe-handle-compact,
@@ -499,10 +533,19 @@ defineExpose({
.pe-edit-inline {
display: flex;
align-items: center;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
flex: 1;
min-width: 0;
width: 100%;
}
.pe-edit-row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.pe-edit-input {
@@ -918,6 +961,8 @@ defineExpose({
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
max-height: 7.5rem;
overflow-y: auto;
}
.pe-edit-suggest li {
@@ -991,6 +1036,34 @@ defineExpose({
opacity: 0.6;
}
/* 左侧点击定位时的高亮(提升)效果 */
.pe-token-compact.is-active,
.pe-token-detail.is-active {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light), var(--shadow-md);
background-color: var(--color-accent-light);
z-index: 2;
}
.pe-token-compact.is-active {
transform: translateY(-1px) scale(1.03);
}
.pe-token-detail.is-active {
transform: translateX(2px);
}
.pe-token-compact.is-active:not(.editing),
.pe-token-detail.is-active:not(.editing) {
animation: locatePulse 0.7s ease-out;
}
@keyframes locatePulse {
0% { box-shadow: 0 0 0 0 var(--color-accent); }
60% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 2px var(--color-accent-light), var(--shadow-md); }
}
.pe-token-compact,
.pe-token-detail {
will-change: transform;
+4
View File
@@ -59,6 +59,8 @@ export const usePromptStore = defineStore('promptStore', {
// 编辑器内的目标文件夹选择:跨页面切换保留,刷新后重置为默认(不参与持久化)
editorSelectedFolderId: '' as string,
editorFolderInitialized: false,
// 映射变更计数:用于驱动依赖翻译结果的视图(如右侧映射)的响应式刷新
mappingVersion: 0,
}),
getters: {
categories: (s) => s.dataset?.categories ?? [],
@@ -537,11 +539,13 @@ export const usePromptStore = defineStore('promptStore', {
if (exist) {
if (!exist.translation) exist.translation = {};
exist.translation[lang] = val;
this.mappingVersion++;
return;
}
const grp = this.ensureCustomGroup();
grp.tags.push({ key, translation: { en: key, [lang]: val } });
rebuildTagIndex(this.dataset);
this.mappingVersion++;
},
ensureCustomGroup(): PromptGroup {
const catName = 'Custom';