Files
prompt/src/components/PromptEditor.vue
T

2208 lines
62 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 { onMounted, onUnmounted, ref, computed, nextTick, watch } from 'vue';
import { usePromptStore } from '../stores/promptStore';
import type { LangCode } from '../types';
import NotificationToast from './NotificationToast.vue';
import PresetDropdown from './PresetDropdown.vue';
const store = usePromptStore();
const draggingIndex = ref<number | null>(null);
const overIndex = ref<number | null>(null);
const dragPreview = ref<HTMLElement | null>(null);
const isDragging = ref(false);
// 指针拖拽新增状态
const insertSide = ref<'before' | 'after' | null>(null);
const pointerId = ref<number | null>(null);
const startX = ref(0);
const startY = ref(0);
const lastX = ref(0);
const lastY = ref(0);
const dragStarted = ref(false);
const DRAG_THRESHOLD = 3; // 像素阈值,避免误触
const editingIndex = ref<number | null>(null);
const editingValue = ref('');
const addingMapIndex = ref<number | null>(null);
const addingMapValue = ref('');
const presetName = ref('');
const viewMode = ref<'compact' | 'detail'>('compact');
const showPresetDropdown = ref(false);
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);
}
// 点击外部关闭下拉菜单
function handleClickOutside(event: Event) {
const target = event.target as HTMLElement;
// 检查点击是否在预设下拉区域内,包括重命名输入框和按钮
if (!target.closest('.pe-presets') && !target.closest('.pd-dropdown')) {
showPresetDropdown.value = false;
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
// 清理事件监听器
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
const selectedLang = computed({
get: () => store.selectedLang,
set: (v: LangCode) => store.setLanguage(v),
});
const tokens = computed(() => store.tokens);
const suggestions = ref<string[]>([]);
const editSuggestions = ref<string[]>([]);
const inputEl = ref<HTMLTextAreaElement | null>(null);
// 注意:ref 在 v-for 中可能成为数组,这里做统一归一化处理
const editEl = ref<HTMLInputElement | HTMLInputElement[] | null>(null);
function currentEditEl(): HTMLInputElement | null {
const raw = editEl.value as any;
if (!raw) return null;
return Array.isArray(raw) ? (raw[0] ?? null) : raw;
}
const priorityStyle = ref<'{}' | '()' | '[]' | '<>' | 'suffix'>('{}');
const priorityStep = ref(1);
function splitTokensLocal(txt: string): string[] {
return txt.split(/[,]/).map(s => s.trim()).filter(s => s.length > 0);
}
function normalizeToken(t: string): string { return t.trim(); }
function normalizePromptLocal(txt: string): string { return splitTokensLocal(txt).join(', '); }
function applyFullPrompt(newText: string) {
const el = inputEl.value;
if (!el) { text.value = newText; return; }
el.focus();
applyTextReplacement(el, 0, text.value.length, newText);
}
function getTokenWeight(token: string): number {
const { core } = store.parseTokenWrappers(token);
const idx = core.lastIndexOf(':');
if (idx > -1) {
const w = parseFloat(core.slice(idx + 1).trim());
return isNaN(w) ? 1 : w;
}
return 1;
}
function hasWeightSuffix(token: string): boolean {
const { core } = store.parseTokenWrappers(token);
return /:\s*\d+(?:\.\d+)?$/.test(core);
}
function roundToDecimals(v: number, decimals: number): number {
const m = Math.pow(10, decimals);
return Math.round(v * m) / m;
}
function adjustWeight(core: string, delta: number): string {
const idx = core.lastIndexOf(':');
let base = core;
let w: number | null = null;
if (idx > -1) {
const num = parseFloat(core.slice(idx + 1).trim());
if (!isNaN(num)) { base = core.slice(0, idx); w = num; }
}
const stepStr = String(priorityStep.value);
const decimals = stepStr.includes('.') ? stepStr.split('.')[1]!.length : 0;
const cur = w == null ? 1.0 : w;
let nw = cur + delta;
if (delta < 0 && nw <= 1.0) return base;
nw = Math.min(2.0, Math.max(0.1, nw));
nw = roundToDecimals(nw, decimals);
return base + ':' + nw;
}
const text = ref('');
watch(text, (val) => {
store.setPromptTextRaw(val);
updateSuggestions();
});
// 当 store.promptText 发生变化(例如点击右侧预设加载)时,主动同步到左侧输入
watch(() => store.promptText, (v) => {
if (text.value !== v) text.value = v;
}, { immediate: true });
function updateSuggestions() {
const el = inputEl.value;
const txt = store.promptText;
let pos = txt.length;
if (el && typeof el.selectionStart === 'number') {
pos = el.selectionStart ?? txt.length;
}
// 计算当前光标所在片段:左右最近的逗号之间
const leftCommaEn = txt.lastIndexOf(',', pos - 1);
const leftCommaCn = txt.lastIndexOf('', pos - 1);
const left = Math.max(leftCommaEn, leftCommaCn);
const rightCommaEn = txt.indexOf(',', pos);
const rightCommaCn = txt.indexOf('', pos);
const rightCandidates = [rightCommaEn, rightCommaCn].filter(i => i !== -1);
const right = rightCandidates.length ? Math.min(...rightCandidates) : txt.length;
const segment = txt.slice(left < 0 ? 0 : left + 1, right).trim();
suggestions.value = store.getSuggestions(segment, 8);
}
function updateEditSuggestions() {
const el = currentEditEl();
const val = editingValue.value || '';
let pos = val.length;
if (el && typeof el.selectionStart === 'number') {
pos = el.selectionStart ?? val.length;
}
// 对编辑输入,使用光标左侧内容作为前缀
const before = val.slice(0, pos);
const match = before.match(/[^,]*$/);
const prefix = (match ? match[0] : before).trim();
editSuggestions.value = store.getSuggestions(prefix, 8);
}
// 计算左侧输入(textarea)基于光标位置的片段替换范围(修剪前后空格)
function getTextSegmentBounds(txt: string, pos: number) {
const leftCommaEn = txt.lastIndexOf(',', pos - 1);
const leftCommaCn = txt.lastIndexOf('', pos - 1);
const left = Math.max(leftCommaEn, leftCommaCn);
const rightCommaEn = txt.indexOf(',', pos);
const rightCommaCn = txt.indexOf('', pos);
const rightCandidates = [rightCommaEn, rightCommaCn].filter(i => i !== -1);
const right = rightCandidates.length ? Math.min(...rightCandidates) : txt.length;
let start = left < 0 ? 0 : left + 1;
let end = right;
while (start < end && txt[start] && /\s/.test(txt[start]!)) start++;
while (end > start && txt[end - 1] && /\s/.test(txt[end - 1]!)) end--;
return { start, end };
}
// 统一的文本替换方法:优先使用原生插入以保留撤回栈,失败时回退
function applyTextReplacement(
el: HTMLTextAreaElement | HTMLInputElement,
start: number,
end: number,
text: string,
) {
try {
if (typeof el.setSelectionRange === 'function') {
el.setSelectionRange(start, end);
}
const ok = (document as any).execCommand && (document as any).execCommand('insertText', false, text);
if (ok) return;
} catch {}
try {
el.setRangeText(text, start, end, 'end');
try {
const ie = new (window as any).InputEvent('input', { bubbles: true, data: text, inputType: 'insertReplacementText' });
el.dispatchEvent(ie);
} catch {
el.dispatchEvent(new Event('input', { bubbles: true }));
}
} catch {
const value = (el as any).value as string;
(el as any).value = value.slice(0, start) + text + value.slice(end);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
}
async function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab') {
// 在光标位置进行补全,不影响撤回
const el = inputEl.value;
if (!el) return;
const pos = el.selectionStart ?? store.promptText.length;
const before = store.promptText.slice(0, pos);
const match = before.match(/[^,]*$/);
const prefix = (match ? match[0] : '').trim();
const { start, end } = getTextSegmentBounds(store.promptText, pos);
const list = store.getSuggestions(prefix, 8);
if (list.length > 0) {
e.preventDefault();
const s = list[0];
if (!s) return;
// 使用原生插入或回退方案,确保撤回可用
applyTextReplacement(el, start, end, s);
await nextTick();
updateSuggestions();
}
}
}
async function copyLeft() {
try {
await navigator.clipboard.writeText(store.promptText);
showNotification('提示词已复制到剪贴板', 'success');
} catch (error) {
showNotification('复制失败,请手动复制', 'error');
}
}
function replaceCnComma() { applyFullPrompt(text.value.replace(//g, ',')); }
function formatPrompt() { applyFullPrompt(normalizePromptLocal(text.value)); }
// 新增功能方法
function toggleUnderscoreSpace() {
const tokens = splitTokensLocal(text.value);
const newTokens = tokens.map(token => {
const { core, wrappers } = store.parseTokenWrappers(token);
let newCore;
if (core.includes('_')) {
newCore = core.replace(/_/g, ' ');
} else if (core.includes(' ')) {
newCore = core.replace(/ /g, '_');
} else {
newCore = core;
}
return store.wrapToken(newCore, wrappers);
});
applyFullPrompt(newTokens.join(', '));
showNotification('已切换下划线/空格格式', 'success');
}
function addWrapperToToken(index: number) {
const tokens = splitTokensLocal(text.value);
if (index < 0 || index >= tokens.length) return;
const token = tokens[index]!;
const parsed = store.parseTokenWrappers(token);
const core = parsed?.core ?? token;
const wrappers = parsed?.wrappers ?? [];
if (priorityStyle.value === 'suffix') {
const newCore = adjustWeight(core, +priorityStep.value);
tokens[index] = store.wrapToken(newCore, wrappers);
} else {
const newWrappers = [...wrappers, priorityStyle.value];
tokens[index] = store.wrapToken(core, newWrappers);
}
applyFullPrompt(tokens.join(', '));
showNotification('已添加优先级', 'success');
}
function removeWrapperFromToken(index: number) {
const tokens = splitTokensLocal(text.value);
if (index < 0 || index >= tokens.length) return;
const token = tokens[index]!;
const { core, wrappers } = store.parseTokenWrappers(token);
if (priorityStyle.value === 'suffix') {
const newCore = adjustWeight(core, -priorityStep.value);
tokens[index] = store.wrapToken(newCore, wrappers);
} else if (wrappers.length > 0) {
const newWrappers = wrappers.slice(0, -1);
tokens[index] = store.wrapToken(core, newWrappers);
}
applyFullPrompt(tokens.join(', '));
showNotification('已调整优先级', 'success');
}
function getTokenWrapperInfo(token: string) {
return store.getTokenWrapperInfo(token);
}
// 指针事件版拖拽:更高性能且可自定义插入指示
function onPointerDown(index: number, e: PointerEvent) {
if (editingIndex.value === index) return;
draggingIndex.value = index;
pointerId.value = e.pointerId;
startX.value = e.clientX;
startY.value = e.clientY;
lastX.value = e.clientX;
lastY.value = e.clientY;
dragStarted.value = false;
isDragging.value = false;
insertSide.value = null;
// 监听全局移动与抬起
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp, { once: true });
}
function handlePointerMove(e: PointerEvent) {
lastX.value = e.clientX;
lastY.value = e.clientY;
const dx = e.clientX - startX.value;
const dy = e.clientY - startY.value;
if (!dragStarted.value && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
dragStarted.value = true;
isDragging.value = true;
if (draggingIndex.value != null) createPointerPreview(draggingIndex.value);
}
if (!isDragging.value) return;
positionPreview(e.clientX, e.clientY);
updateOverIndexAndSide(e.clientX, e.clientY);
}
function handlePointerUp(e: PointerEvent) {
window.removeEventListener('pointermove', handlePointerMove);
if (!dragStarted.value || draggingIndex.value == null) {
cleanupDrag();
return;
}
const from = draggingIndex.value!;
const j = overIndex.value;
const side = insertSide.value;
if (j != null && side) {
let to = j;
if (side === 'before') {
to = j - (from < j ? 1 : 0);
} else {
to = j + (from > j ? 1 : 0);
}
const list = splitTokensLocal(text.value);
if (to < 0) to = 0;
if (to >= list.length) to = list.length - 1;
const [item] = list.splice(from, 1);
if (item != null) list.splice(to, 0, item);
applyFullPrompt(list.join(', '));
showNotification('已重新排序', 'success');
}
cleanupDrag();
}
function cleanupDrag() {
draggingIndex.value = null;
overIndex.value = null;
isDragging.value = false;
insertSide.value = null;
pointerId.value = null;
if (dragPreview.value) {
document.body.removeChild(dragPreview.value);
dragPreview.value = null;
}
}
function createPointerPreview(index: number) {
const token = tokens.value[index] || '';
const translation = displayTrans(token);
const preview = document.createElement('div');
preview.className = 'drag-preview';
preview.innerHTML = `
<div class="drag-preview-content">
<span class="drag-preview-key">${token}</span>
<span class="drag-preview-arrow">→</span>
<span class="drag-preview-trans">${translation}</span>
</div>
`;
preview.style.position = 'fixed';
preview.style.top = '0';
preview.style.left = '0';
preview.style.zIndex = '1000';
preview.style.pointerEvents = 'none';
;(preview.style as any).contain = 'layout style paint';
preview.style.willChange = 'transform, opacity';
document.body.appendChild(preview);
dragPreview.value = preview;
}
function positionPreview(x: number, y: number) {
if (!dragPreview.value) return;
dragPreview.value.style.transform = `translate(${x + 12}px, ${y + 12}px)`;
}
function updateOverIndexAndSide(x: number, y: number) {
insertSide.value = null;
overIndex.value = null;
const el = document.elementFromPoint(x, y) as HTMLElement | null;
if (!el) return;
const tokenEl = el.closest('.pe-token-compact, .pe-token-detail') as HTMLElement | null;
if (!tokenEl) return;
const idxAttr = tokenEl.getAttribute('data-index');
if (idxAttr == null) return;
const idx = parseInt(idxAttr, 10);
if (Number.isNaN(idx)) return;
if (idx === draggingIndex.value) { overIndex.value = null; insertSide.value = null; return; }
const rect = tokenEl.getBoundingClientRect();
const midX = rect.left + rect.width / 2;
overIndex.value = idx;
insertSide.value = x < midX ? 'before' : 'after';
}
function beginEdit(i: number) {
editingIndex.value = i;
editingValue.value = tokens.value[i] ?? '';
addingMapIndex.value = null;
nextTick(() => {
const el = currentEditEl();
if (el) {
el.focus();
try { el.setSelectionRange(0, editingValue.value.length); } catch {}
}
});
}
function commitEdit() {
if (editingIndex.value == null) return;
const tokens = splitTokensLocal(text.value);
const i = editingIndex.value!;
if (i >= 0 && i < tokens.length) {
tokens[i] = normalizeToken(editingValue.value);
applyFullPrompt(tokens.join(', '));
}
editingIndex.value = null;
}
function cancelEdit() { editingIndex.value = null; }
function showAddMap(i: number) {
addingMapIndex.value = i; addingMapValue.value = '';
editingIndex.value = null;
}
function commitAddMap() {
if (addingMapIndex.value == null) return;
const key = tokens.value[addingMapIndex.value];
if (!key) return;
store.addMapping(key, selectedLang.value, addingMapValue.value.trim());
addingMapIndex.value = null; addingMapValue.value = '';
}
function removeToken(i: number) {
const tokens = splitTokensLocal(text.value);
if (i < 0 || i >= tokens.length) return;
tokens.splice(i, 1);
applyFullPrompt(tokens.join(', '));
}
function addTokenAfter(i: number) {
const tokens = splitTokensLocal(text.value);
tokens.splice(i + 1, 0, normalizeToken('new_token'));
applyFullPrompt(tokens.join(', '));
}
function savePreset() {
if (!presetName.value.trim()) {
showNotification('请输入预设名称', 'error');
return;
}
const name = presetName.value.trim();
// 只保存到新的扩展预设系统
const defaultFolder = store.presetManagement?.settings?.defaultFolder;
store.createExtendedPreset({
name: name,
type: 'positive',
content: store.promptText,
description: '从编辑器快速保存',
folderId: defaultFolder
});
showNotification(`预设「${name}」已保存到预设管理`, 'success');
presetName.value = '';
}
// 预设下拉组件的事件处理
function handlePresetLoad(name: string) {
// 优先从扩展预设中查找
const extendedPreset = store.extendedPresets.find(p => p.name === name);
if (extendedPreset) {
store.setPromptTextRaw(extendedPreset.content);
text.value = extendedPreset.content;
} else {
// 回退到旧预设系统
store.loadPreset(name);
text.value = store.promptText;
}
showNotification(`已加载预设「${name}」`, 'success');
}
function handlePresetSave(name: string) {
store.savePreset(name);
showNotification(`预设「${name}」已保存`, 'success');
}
function handlePresetDelete(name: string) {
store.deletePreset(name);
showNotification(`预设「${name}」已删除`, 'info');
}
function handlePresetRename(oldName: string, newName: string) {
store.renamePreset(oldName, newName);
showNotification(`预设已重命名为「${newName}」`, 'success');
}
async function applySuggestion(s: string) {
const el = inputEl.value;
if (!el) return;
// 确保输入框获得焦点,避免点击建议导致焦点丢失
el.focus();
const pos = el.selectionStart ?? store.promptText.length;
const { start, end } = getTextSegmentBounds(store.promptText, pos);
// 使用原生插入或回退方式替换片段,确保撤回可用
applyTextReplacement(el, start, end, s);
await nextTick();
updateSuggestions();
}
function onEditKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const el = currentEditEl();
if (!el) return;
const val = editingValue.value || '';
const pos = el.selectionStart ?? val.length;
const before = val.slice(0, pos);
const match = before.match(/[^,]*$/);
const prefix = (match ? match[0] : '').trim();
const list = store.getSuggestions(prefix, 8);
if (list.length > 0) {
e.preventDefault();
const s = list[0];
if (s) applyTextReplacement(el, 0, val.length, s);
updateEditSuggestions();
}
}
function applyEditSuggestion(s: string) {
const el = currentEditEl();
if (!el) return;
// 保持焦点在编辑输入上
el.focus();
const val = editingValue.value || '';
// 直接替换整个输入为建议,保证撤回可用
applyTextReplacement(el, 0, val.length, s);
updateEditSuggestions();
}
function displayTrans(key: string): string {
const { core, wrappers } = store.parseTokenWrappers(key);
const m = core.match(/:(\d+(?:\.\d+)?)$/);
const base = m ? core.slice(0, core.lastIndexOf(':')) : core;
const suffix = m ? ':' + m[1]! : '';
const tag = store.getTagByKey(base);
const translatedCore = tag?.translation?.[selectedLang.value] ?? tag?.key ?? base;
return store.wrapToken(translatedCore + suffix, wrappers);
}
function isRemoveDisabled(token: string): boolean {
const info = getTokenWrapperInfo(token);
return info.wrapperCount === 0 && !hasWeightSuffix(token);
}
</script>
<template>
<div class="pe-root">
<header class="pe-toolbar">
<div class="pe-left">
<label>语言</label>
<select v-model="selectedLang">
<option v-for="l in store.languages" :key="l" :value="l">{{ l }}</option>
</select>
<button @click="copyLeft" title="复制提示词到剪贴板">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
<path d="m5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/>
</svg>
复制提示词
</button>
</div>
<div class="pe-right">
<input class="pe-preset-name" placeholder="保存为预设名称" v-model="presetName" />
<button @click="savePreset" title="保存当前提示词为预设">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" stroke="currentColor" stroke-width="2"/>
<polyline points="17,21 17,13 7,13 7,21" stroke="currentColor" stroke-width="2"/>
<polyline points="7,3 7,8 15,8" stroke="currentColor" stroke-width="2"/>
</svg>
保存预设
</button>
<div class="pe-presets">
<button
class="pe-preset-toggle"
@click="showPresetDropdown = !showPresetDropdown"
title="快速预设"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z" fill="currentColor"/>
</svg>
快速预设
<svg
width="12" height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
:class="{ 'rotate-180': showPresetDropdown }"
class="dropdown-arrow"
>
<polyline points="6,9 12,15 18,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<PresetDropdown
:show="showPresetDropdown"
@close="showPresetDropdown = false"
@load="handlePresetLoad"
@save="handlePresetSave"
@delete="handlePresetDelete"
@rename="handlePresetRename"
/>
</div>
</div>
</header>
<main class="pe-main">
<section class="pe-left-pane">
<div class="pe-section-title">提示词输入逗号分隔</div>
<textarea
ref="inputEl"
class="pe-input"
v-model="text"
@keydown="onKeyDown"
@click="updateSuggestions"
@keyup="updateSuggestions"
placeholder="例如:1girl, aaa, bbb, ccc"
></textarea>
<div class="pe-input-actions">
<button @click="replaceCnComma" title="将中文逗号替换为英文逗号">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2"/>
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2"/>
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2"/>
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2"/>
</svg>
替换中文逗号
</button>
<button @click="formatPrompt" title="格式化提示词为标准格式">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="4 7 4 4 20 4 20 7" stroke="currentColor" stroke-width="2"/>
<line x1="9" y1="20" x2="15" y2="20" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="4" x2="12" y2="20" stroke="currentColor" stroke-width="2"/>
</svg>
格式化提示词
</button>
<button @click="toggleUnderscoreSpace" title="切换下划线和空格格式">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18" stroke="currentColor" stroke-width="2"/>
<path d="M8 8l4-4 4 4" stroke="currentColor" stroke-width="2"/>
<path d="M8 16l4 4 4-4" stroke="currentColor" stroke-width="2"/>
</svg>
切换 _/空格
</button>
<div class="pe-priority-group">
<label class="pe-priority-label">优先级样式</label>
<select class="pe-priority-select" v-model="priorityStyle" title="选择新增优先级的样式">
<option value="{}">{}</option>
<option value="()">()</option>
<option value="[]">[]</option>
<option value="<>">&lt;&gt;</option>
<option value="suffix">后缀数字</option>
</select>
<label class="pe-priority-label">后缀数字间隔</label>
<input
type="number"
class="pe-priority-step"
v-model.number="priorityStep"
title="设置增减间隔"
min="0.01"
step="0.01"
placeholder="1"
/>
</div>
</div>
<ul class="pe-suggest" v-if="suggestions.length">
<li
v-for="s in suggestions"
:key="s"
@mousedown.prevent
@click="applySuggestion(s)"
>{{ s }}</li>
</ul>
</section>
<section class="pe-right-pane">
<div class="pe-section-title mode">
<span>提示词映射双击修改</span>
<div class="pe-mode-switch">
<button :class="{ active: viewMode==='compact' }" @click="viewMode='compact'">精简视图</button>
<button :class="{ active: viewMode==='detail' }" @click="viewMode='detail'">详细视图</button>
</div>
</div>
<div class="pe-drag-container" :class="{ 'is-dragging': isDragging }">
<div class="pe-tokens-compact" v-if="viewMode === 'compact'">
<div
v-for="(k,i) in tokens"
:key="k + '_' + 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
}"
class="pe-token-compact"
@pointerdown="onPointerDown(i, $event)"
@dblclick="beginEdit(i)"
:title="`${k} → ${displayTrans(k)}`"
>
<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="编辑提示词"
/>
<ul class="pe-edit-suggest" v-if="editSuggestions.length">
<li
v-for="s in editSuggestions"
:key="'e_'+s"
@mousedown.prevent
@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-arrow-compact"></span>
<span class="pe-trans-compact" :class="{ unmapped: displayTrans(k) === k }">
{{ displayTrans(k) }}
</span>
</div>
<div class="pe-token-controls-compact">
<button @click="addWrapperToToken(i)" class="pe-add-wrapper-btn" :title="`添加优先级(样式:${priorityStyle}`">
<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"/>
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<button
@click="removeWrapperFromToken(i)"
class="pe-remove-wrapper-btn"
title="移除优先级"
:disabled="isRemoveDisabled(k)"
>
<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"/>
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<button @click="removeToken(i)" class="pe-remove-btn" title="删除此词">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2"/>
<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" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
</div>
</div>
<div class="pe-tokens-detail" v-else>
<div
v-for="(k,i) in tokens"
:key="k + '_' + 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 || addingMapIndex === i
}"
class="pe-token-detail"
@pointerdown="onPointerDown(i, $event)"
>
<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-arrow-detail"></span>
<span class="pe-trans-detail" :class="{ unmapped: displayTrans(k) === k }">{{ displayTrans(k) }}</span>
</div>
<div class="pe-token-controls">
<button v-if="displayTrans(k) === k" class="pe-add-map-btn" @click="showAddMap(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"/>
</svg>
</button>
<button @click="addWrapperToToken(i)" class="pe-add-wrapper-detail-btn" :title="`添加优先级(样式:${priorityStyle}`">
<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"/>
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<button
@click="removeWrapperFromToken(i)"
class="pe-remove-wrapper-detail-btn"
title="移除优先级"
:disabled="isRemoveDisabled(k)"
>
<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"/>
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<button class="pe-add-after-btn" @click="addTokenAfter(i)" title="在后添加">
<svg width="14" height="14" 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="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<button class="pe-remove-detail-btn" @click="removeToken(i)" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2"/>
<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" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
</div>
<div v-if="editingIndex === i" class="pe-edit-panel">
<input
ref="editEl"
v-model="editingValue"
@keydown="onEditKeyDown"
@keyup.enter="commitEdit"
@click="updateEditSuggestions"
@keyup="updateEditSuggestions"
placeholder="编辑词条..."
/>
<ul class="pe-edit-suggest" v-if="editSuggestions.length">
<li
v-for="s in editSuggestions"
:key="'p_'+s"
@mousedown.prevent
@click="applyEditSuggestion(s)"
>{{ s }}</li>
</ul>
<div class="pe-edit-actions">
<button @click="commitEdit" class="pe-confirm-btn">确定</button>
<button @click="cancelEdit" class="pe-cancel-btn">取消</button>
</div>
</div>
<div v-if="addingMapIndex === i" class="pe-add-panel">
<input v-model="addingMapValue" :placeholder="`请输入 ${selectedLang} 的翻译`" @keyup.enter="commitAddMap" />
<div class="pe-add-actions">
<button @click="commitAddMap" class="pe-confirm-btn">添加</button>
<button @click="() => { addingMapIndex = null; addingMapValue = ''; }" class="pe-cancel-btn">取消</button>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- 通知组件 -->
<NotificationToast
:message="notification.message"
:type="notification.type"
:show="notification.show"
/>
</div>
</template>
<style scoped>
.pe-root {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
}
.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);
gap: 1rem;
flex-wrap: wrap;
}
.pe-left, .pe-right {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.pe-left label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.pe-left select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.pe-left select:hover {
border-color: var(--color-border-hover);
}
.pe-left select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pe-left button, .pe-right button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
line-height: 1;
}
.pe-left button:hover, .pe-right button:hover {
background-color: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
.pe-preset-name {
width: 200px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
transition: all 0.2s ease;
}
.pe-preset-name:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pe-presets {
position: relative;
}
.pe-preset-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.pe-preset-toggle:hover {
background-color: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
.dropdown-arrow {
transition: transform 0.2s ease;
margin-left: 0.25rem;
}
.dropdown-arrow.rotate-180 {
transform: rotate(180deg);
}
.pe-preset-dropdown {
position: absolute;
top: 100%;
right: 0;
z-index: 50;
min-width: 320px;
max-height: 400px;
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
margin-top: 0.5rem;
overflow: hidden;
}
.pe-preset-search-wrapper {
padding: 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.pe-preset-search {
width: 100%;
padding: 0.5rem 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-size: 0.875rem;
transition: all 0.2s ease;
}
.pe-preset-search:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pe-preset-list {
max-height: 300px;
overflow-y: auto;
}
.pe-preset-empty {
padding: 2rem;
text-align: center;
color: var(--color-text-tertiary);
font-size: 0.875rem;
}
.pe-preset-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.pe-preset-item:last-child {
border-bottom: none;
}
.pe-preset-load {
flex: 1;
text-align: left;
padding: 0.25rem 0.5rem;
border: none;
background: transparent;
color: var(--color-text-primary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
}
.pe-preset-load:hover {
background-color: var(--color-bg-secondary);
}
.pe-preset-meta {
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.pe-preset-rename, .pe-preset-delete {
padding: 0.25rem;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
}
.pe-preset-rename:hover {
background-color: var(--color-bg-secondary);
color: var(--color-accent);
}
.pe-preset-delete:hover {
background-color: var(--color-error);
color: white;
}
.pe-preset-rename-input {
flex: 1;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
}
.pe-preset-rename-ok, .pe-preset-rename-cancel {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.pe-preset-rename-ok:hover {
background-color: var(--color-success);
color: white;
border-color: var(--color-success);
}
.pe-preset-rename-cancel:hover {
background-color: var(--color-bg-tertiary);
}
.pe-main {
display: grid;
grid-template-columns: 1fr 1fr;
height: calc(100vh - 8rem);
gap: 1px;
background-color: var(--color-border);
}
.pe-left-pane, .pe-right-pane {
padding: 1.5rem;
background-color: var(--color-bg-primary);
overflow: auto;
}
.pe-section-title {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pe-mode-switch {
display: flex;
gap: 0.25rem;
background-color: var(--color-bg-secondary);
padding: 0.25rem;
border-radius: var(--radius-md);
}
.pe-mode-switch button {
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
}
.pe-mode-switch button.active {
background-color: var(--color-accent);
color: white;
box-shadow: var(--shadow-sm);
}
.pe-input {
width: 100%;
height: 200px;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
transition: all 0.2s ease;
}
.pe-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pe-input-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.pe-input-actions button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
line-height: 1;
}
.pe-input-actions button:hover {
background-color: var(--color-bg-secondary);
border-color: var(--color-border-hover);
}
.pe-priority-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.pe-priority-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.pe-priority-select, .pe-priority-step {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.pe-priority-select:hover, .pe-priority-step:hover {
border-color: var(--color-border-hover);
}
.pe-priority-select:focus, .pe-priority-step:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pe-suggest {
list-style: none;
margin: 1rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pe-suggest li {
padding: 0.375rem 0.75rem;
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.pe-suggest li:hover {
background-color: var(--color-accent);
color: white;
border-color: var(--color-accent);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* 编辑输入建议列表(紧凑尺寸) */
.pe-edit-suggest {
list-style: none;
margin: 0.25rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.pe-edit-suggest li {
padding: 0.25rem 0.5rem;
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.pe-edit-suggest li:hover {
background-color: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
/* 紧凑行视图 */
.pe-tokens-compact {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
}
.pe-token-compact {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
cursor: grab;
transition: all 0.2s ease;
max-width: 100%;
}
.pe-token-compact:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-sm);
}
/* 精简视图编辑态美化 */
.pe-token-compact.editing {
cursor: default;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
background-color: var(--color-bg-primary);
}
.pe-token-compact[draggable="false"] {
cursor: default;
}
.pe-token-compact.editing .pe-handle-compact,
.pe-token-compact.editing .pe-token-content,
.pe-token-compact.editing .pe-token-controls-compact {
display: none;
}
.pe-edit-inline {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.pe-edit-input {
flex: 1;
min-width: 0;
padding: 0.375rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
font-size: 0.8125rem;
transition: all 0.2s ease;
}
.pe-edit-input::placeholder {
color: var(--color-text-tertiary);
}
.pe-edit-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
background-color: var(--color-bg-primary);
}
.pe-edit-actions {
display: flex;
gap: 0.25rem;
}
.pe-edit-save-btn, .pe-edit-cancel-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 1px solid var(--color-border);
background-color: var(--color-bg-primary);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
}
.pe-edit-save-btn:hover {
background-color: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.pe-edit-cancel-btn:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.pe-token-compact.dragging {
opacity: 0.5;
transform: rotate(2deg);
cursor: grabbing;
}
.pe-token-compact.drag-over {
border-color: var(--color-accent);
background-color: var(--color-accent-light);
}
.pe-handle-compact {
cursor: grab;
user-select: none;
color: var(--color-text-tertiary);
font-size: 0.75rem;
padding: 0.125rem;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
flex-shrink: 0;
}
.pe-handle-compact:hover {
color: var(--color-text-secondary);
background-color: var(--color-bg-tertiary);
}
.pe-handle-compact:active {
cursor: grabbing;
}
.pe-token-content {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1;
min-width: 0;
}
.pe-key-compact {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-primary);
background-color: var(--color-bg-tertiary);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
flex-shrink: 0;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pe-arrow-compact {
color: var(--color-text-tertiary);
font-size: 0.75rem;
flex-shrink: 0;
}
.pe-trans-compact {
font-size: 0.75rem;
color: var(--color-text-primary);
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pe-trans-compact.unmapped {
color: var(--color-error);
font-style: italic;
opacity: 0.8;
}
.pe-remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: 1px solid var(--color-border);
background-color: var(--color-bg-primary);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
opacity: 0.7;
}
.pe-token-compact:hover .pe-remove-btn {
opacity: 1;
}
.pe-remove-btn:hover {
background-color: var(--color-error);
color: white;
border-color: var(--color-error);
transform: scale(1.05);
}
/* 精简视图的包裹层控制按钮 */
.pe-token-controls-compact {
display: flex;
gap: 0.125rem;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.pe-token-compact:hover .pe-token-controls-compact {
opacity: 1;
}
.pe-add-wrapper-btn, .pe-remove-wrapper-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: 1px solid var(--color-border);
background-color: var(--color-bg-primary);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.pe-add-wrapper-btn:hover {
background-color: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.pe-remove-wrapper-btn:hover:not(:disabled) {
background-color: var(--color-warning);
color: white;
border-color: var(--color-warning);
}
.pe-remove-wrapper-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* 详细列表视图 */
.pe-tokens-detail {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
}
.pe-token-detail {
position: relative;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all 0.2s ease;
overflow: hidden;
}
.pe-token-detail:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-sm);
}
.pe-token-detail.editing {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pe-token-detail.dragging {
opacity: 0.5;
transform: rotate(2deg);
}
.pe-token-detail.drag-over {
border-color: var(--color-accent);
background-color: var(--color-accent-light);
}
.pe-token-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
}
.pe-handle-detail {
cursor: grab;
user-select: none;
color: var(--color-text-tertiary);
font-size: 1rem;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
flex-shrink: 0;
}
.pe-handle-detail:hover {
color: var(--color-text-secondary);
background-color: var(--color-bg-tertiary);
}
.pe-handle-detail:active {
cursor: grabbing;
}
.pe-token-main {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
}
.pe-token-main:hover {
background-color: var(--color-bg-tertiary);
}
.pe-key-detail {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary);
background-color: var(--color-bg-tertiary);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.pe-arrow-detail {
color: var(--color-text-tertiary);
font-weight: 500;
flex-shrink: 0;
}
.pe-trans-detail {
color: var(--color-text-primary);
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pe-trans-detail.unmapped {
color: var(--color-error);
font-style: italic;
}
.pe-token-controls {
display: flex;
gap: 0.25rem;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.pe-token-detail:hover .pe-token-controls {
opacity: 1;
}
.pe-add-map-btn, .pe-add-after-btn, .pe-remove-detail-btn,
.pe-add-wrapper-detail-btn, .pe-remove-wrapper-detail-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--color-border);
background-color: var(--color-bg-primary);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s ease;
}
.pe-add-map-btn:hover {
background-color: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.pe-add-after-btn:hover {
background-color: var(--color-success);
color: white;
border-color: var(--color-success);
}
.pe-add-wrapper-detail-btn:hover {
background-color: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.pe-remove-wrapper-detail-btn:hover:not(:disabled) {
background-color: var(--color-warning);
color: white;
border-color: var(--color-warning);
}
.pe-remove-wrapper-detail-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pe-remove-detail-btn:hover {
background-color: var(--color-error);
color: white;
border-color: var(--color-error);
}
.pe-edit-panel, .pe-add-panel {
padding: 0.75rem;
background-color: var(--color-bg-primary);
border-top: 1px solid var(--color-border);
}
.pe-edit-panel input, .pe-add-panel input {
width: 100%;
padding: 0.5rem 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-size: 0.875rem;
margin-bottom: 0.5rem;
transition: all 0.2s ease;
}
.pe-edit-panel input:focus, .pe-add-panel input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.pe-edit-actions, .pe-add-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.pe-confirm-btn, .pe-cancel-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.pe-confirm-btn {
background-color: var(--color-accent);
color: white;
border-color: var(--color-accent);
}
.pe-confirm-btn:hover {
background-color: var(--color-accent-hover);
}
.pe-cancel-btn {
background-color: var(--color-bg-primary);
color: var(--color-text-secondary);
}
.pe-cancel-btn:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.pe-main {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.pe-left-pane {
border-bottom: 1px solid var(--color-border);
}
}
@media (max-width: 768px) {
.pe-toolbar {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.pe-left, .pe-right {
justify-content: center;
}
.pe-left-pane, .pe-right-pane {
padding: 1rem;
}
.pe-input {
height: 150px;
}
.pe-preset-name {
width: 100%;
}
.pe-preset-dropdown {
position: static;
margin-top: 0.5rem;
box-shadow: none;
border: 1px solid var(--color-border);
min-width: auto;
}
.pe-tokens-compact {
gap: 0.375rem;
}
.pe-token-compact {
padding: 0.375rem 0.5rem;
}
}
@media (max-width: 640px) {
.pe-input-actions {
flex-direction: column;
}
.pe-input-actions button {
width: 100%;
justify-content: center;
}
.pe-token-header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.pe-token-main {
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
.pe-token-controls {
align-self: flex-end;
opacity: 1;
}
.pe-remove-btn {
opacity: 1;
}
.pe-token-controls-compact {
opacity: 1;
}
.pe-edit-actions, .pe-add-actions {
flex-direction: column;
}
.pe-confirm-btn, .pe-cancel-btn {
width: 100%;
}
.pe-tokens-compact {
gap: 0.25rem;
}
.pe-token-compact {
padding: 0.25rem 0.375rem;
gap: 0.25rem;
}
.pe-key-compact {
font-size: 0.6875rem;
max-width: 80px;
}
.pe-trans-compact {
font-size: 0.6875rem;
}
.pe-arrow-compact {
font-size: 0.6875rem;
}
}
/* 拖拽状态样式 */
.pe-token-compact[draggable="true"]:hover .pe-handle-compact,
.pe-token-detail[draggable="true"]:hover .pe-handle-detail {
color: var(--color-accent);
}
/* 拖拽预览样式 */
.drag-preview {
background-color: var(--color-bg-primary);
border: 2px solid var(--color-accent);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
box-shadow: var(--shadow-md);
opacity: 0.9;
}
.drag-preview-content {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.drag-preview-key {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-primary);
background-color: var(--color-bg-tertiary);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
}
.drag-preview-arrow {
color: var(--color-text-tertiary);
font-weight: 500;
}
.drag-preview-trans {
color: var(--color-text-primary);
font-weight: 500;
}
/* 拖拽占位符样式 */
.pe-token-compact.drag-placeholder,
.pe-token-detail.drag-placeholder {
position: relative;
}
.pe-token-compact.drag-placeholder::before,
.pe-token-detail.drag-placeholder::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border: 2px dashed var(--color-accent);
border-radius: var(--radius-md);
background-color: var(--color-accent-light);
opacity: 0.3;
animation: pulse 1s ease-in-out infinite alternate;
}
@keyframes pulse {
from {
opacity: 0.2;
}
to {
opacity: 0.5;
}
}
/* 改进拖拽中的样式 */
.pe-token-compact.dragging,
.pe-token-detail.dragging {
opacity: 0.3;
transform: scale(0.95) rotate(2deg);
cursor: grabbing;
z-index: 1000;
box-shadow: var(--shadow-lg);
}
.pe-token-compact.drag-over,
.pe-token-detail.drag-over {
border-color: var(--color-accent);
background-color: var(--color-accent-light);
transform: scale(1.02);
transition: all 0.2s ease;
}
/* 插入方向指示:目标项向前/后移动并显示清晰插入方向 */
.pe-token-compact.insert-before,
.pe-token-detail.insert-before {
transform: translateX(10px);
border-color: var(--color-accent);
}
.pe-token-compact.insert-after,
.pe-token-detail.insert-after {
transform: translateX(-10px);
border-color: var(--color-accent);
}
.pe-token-compact.insert-before::before,
.pe-token-detail.insert-before::before {
content: '';
position: absolute;
left: -6px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background-color: var(--color-accent);
border-radius: 2px;
opacity: 0.6;
}
.pe-token-compact.insert-after::after,
.pe-token-detail.insert-after::after {
content: '';
position: absolute;
right: -6px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background-color: var(--color-accent);
border-radius: 2px;
opacity: 0.6;
}
/* 拖拽容器样式 */
.pe-drag-container {
position: relative;
min-height: 200px;
transition: all 0.1s ease;
}
.pe-drag-container.is-dragging {
background-color: var(--color-bg-secondary);
border: 2px dashed var(--color-accent);
border-radius: var(--radius-lg);
padding: 0.5rem;
}
.pe-drag-container.is-dragging::after {
content: '拖拽到此处重新排序';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-tertiary);
font-size: 0.875rem;
pointer-events: none;
opacity: 0.5;
}
/* 预先启用复合层以提升拖拽流畅度 */
.pe-token-compact,
.pe-token-detail {
will-change: transform;
}
/* 加载和过渡动画 */
.pe-token-compact, .pe-token-detail {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 下拉菜单动画 */
.dropdown-enter-active {
transition: all 0.2s ease-out;
}
.dropdown-leave-active {
transition: all 0.2s ease-in;
}
.dropdown-enter-from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
/* 滚动条样式 */
.pe-left-pane::-webkit-scrollbar,
.pe-right-pane::-webkit-scrollbar,
.pe-preset-list::-webkit-scrollbar {
width: 6px;
}
.pe-left-pane::-webkit-scrollbar-track,
.pe-right-pane::-webkit-scrollbar-track,
.pe-preset-list::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
.pe-left-pane::-webkit-scrollbar-thumb,
.pe-right-pane::-webkit-scrollbar-thumb,
.pe-preset-list::-webkit-scrollbar-thumb {
background: var(--color-border-hover);
border-radius: 3px;
}
.pe-left-pane::-webkit-scrollbar-thumb:hover,
.pe-right-pane::-webkit-scrollbar-thumb:hover,
.pe-preset-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* 保证按钮内图标不压缩文本,提升对齐与可读性 */
.pe-left button svg,
.pe-right button svg,
.pe-input-actions button svg,
.pe-preset-toggle svg {
flex-shrink: 0;
}
</style>