在提示词页面也添加翻译支持

This commit is contained in:
2026-06-23 09:19:24 +08:00
parent 63dbc5cfff
commit 875eb21241
2 changed files with 205 additions and 16 deletions
+187 -13
View File
@@ -1,11 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed, reactive, nextTick, watch } from 'vue';
import { usePromptStore } from '../stores/promptStore'; import { usePromptStore } from '../stores/promptStore';
import type { LangCode, PromptGroup, PromptTag } from '../types'; import type { LangCode, PromptTag } from '../types';
const store = usePromptStore(); const store = usePromptStore();
const draggingIndex = ref<number | null>(null); const draggingIndex = ref<number | null>(null);
const overIndex = ref<number | null>(null); const overIndex = ref<number | null>(null);
const keyInputRefs = ref<HTMLInputElement[]>([]);
const tagIds = new WeakMap<PromptTag, string>();
let tagIdSeq = 0;
type TagDraft = {
key: string;
translation: string;
};
const drafts = reactive<Record<string, TagDraft>>({});
const translating = reactive<Record<string, boolean>>({});
const translateErrors = reactive<Record<string, string>>({});
const languages = computed(() => store.languages); const languages = computed(() => store.languages);
const selectedLang = computed({ const selectedLang = computed({
@@ -18,6 +30,37 @@ const currentGroup = computed(() => store.currentGroup);
const filteredTags = computed(() => store.filteredTags); const filteredTags = computed(() => store.filteredTags);
const isSearching = computed(() => store.searchQuery.trim().length > 0); const isSearching = computed(() => store.searchQuery.trim().length > 0);
watch(selectedLang, () => {
for (const tag of filteredTags.value) {
const id = getTagId(tag);
drafts[id] = {
key: tag.key,
translation: displayTranslation(tag),
};
delete translateErrors[id];
}
});
function getTagId(tag: PromptTag): string {
let id = tagIds.get(tag);
if (!id) {
id = `tag_${++tagIdSeq}`;
tagIds.set(tag, id);
}
return id;
}
function getDraft(tag: PromptTag): TagDraft {
const id = getTagId(tag);
if (!drafts[id]) {
drafts[id] = {
key: tag.key,
translation: displayTranslation(tag),
};
}
return drafts[id];
}
function onDragStart(index: number) { function onDragStart(index: number) {
if (isSearching.value) return; if (isSearching.value) return;
draggingIndex.value = index; draggingIndex.value = index;
@@ -42,22 +85,40 @@ function displayTranslation(tag: PromptTag): string {
return tag.translation?.[selectedLang.value] ?? tag.key; return tag.translation?.[selectedLang.value] ?? tag.key;
} }
function updateKey(tag: PromptTag, val: string) { function commitKey(tag: PromptTag) {
const gid = currentGroup.value?.id; const gid = currentGroup.value?.id;
if (!gid) return; if (!gid) return;
store.updateTagKey(gid, tag.key, val); const draft = getDraft(tag);
const nextKey = draft.key.trim();
if (!nextKey || nextKey === tag.key) {
draft.key = tag.key;
return;
}
const oldKey = tag.key;
store.updateTagKey(gid, oldKey, nextKey);
if (tag.key !== oldKey) {
if (draft.translation === oldKey) {
draft.translation = nextKey;
}
draft.key = tag.key;
}
} }
function updateTrans(tag: PromptTag, val: string) { function commitTrans(tag: PromptTag) {
const gid = currentGroup.value?.id; const gid = currentGroup.value?.id;
if (!gid) return; if (!gid) return;
store.setTranslation(gid, tag.key, selectedLang.value, val); const draft = getDraft(tag);
store.setTranslation(gid, tag.key, selectedLang.value, draft.translation.trim());
} }
function addTag() { async function addTag() {
const gid = currentGroup.value?.id; const gid = currentGroup.value?.id;
if (!gid) return; if (!gid) return;
store.setSearch('');
store.addTag(gid); store.addTag(gid);
await nextTick();
keyInputRefs.value[0]?.focus();
keyInputRefs.value[0]?.select();
} }
function removeTag(tag: PromptTag) { function removeTag(tag: PromptTag) {
@@ -75,9 +136,50 @@ function confirmRemoveTag(tag: PromptTag) {
function toggleHidden(tag: PromptTag) { function toggleHidden(tag: PromptTag) {
const gid = currentGroup.value?.id; const gid = currentGroup.value?.id;
if (!gid) return; if (!gid) return;
commitKey(tag);
store.toggleHidden(gid, tag.key); store.toggleHidden(gid, tag.key);
} }
function cleanPromptKey(key: string): string {
return key
.replace(/^[([{<]+/, '')
.replace(/[)\]}>]+$/, '')
.replace(/_/g, ' ')
.trim();
}
async function translateTag(tag: PromptTag) {
const gid = currentGroup.value?.id;
if (!gid) return;
commitKey(tag);
const id = getTagId(tag);
const key = tag.key.trim();
if (!key || translating[id]) return;
translating[id] = true;
delete translateErrors[id];
try {
const target = selectedLang.value === 'zh_CN' ? 'zh' : selectedLang.value;
const text = cleanPromptKey(key);
const url = `https://sywb.top/api/translate2?text=${encodeURIComponent(text)}&sourceLang=auto&targetLang=${target}`;
const res = await fetch(url);
const data = await res.json();
if (!data.success || !data.translation) {
throw new Error(data.message || '翻译失败');
}
const translation = String(data.translation).trim();
const draft = getDraft(tag);
draft.translation = translation;
store.setTranslation(gid, key, selectedLang.value, translation);
} catch (error: any) {
translateErrors[id] = error?.message || '翻译失败';
} finally {
translating[id] = false;
}
}
function exportAll() { function exportAll() {
const json = store.exportToJson(); const json = store.exportToJson();
const blob = new Blob([json], { type: 'application/json' }); const blob = new Blob([json], { type: 'application/json' });
@@ -244,7 +346,7 @@ function resetDefault() {
<div v-else class="pm-tags-wrapper"> <div v-else class="pm-tags-wrapper">
<TransitionGroup name="list" tag="ul" class="pm-tags-list" :css="!isSearching"> <TransitionGroup name="list" tag="ul" class="pm-tags-list" :css="!isSearching">
<li v-for="(t, ti) in filteredTags" :key="t.key + '_' + ti" <li v-for="(t, ti) in filteredTags" :key="getTagId(t)"
class="pm-tag-item" class="pm-tag-item"
:draggable="!isSearching" :draggable="!isSearching"
@dragstart="onDragStart(ti)" @dragstart="onDragStart(ti)"
@@ -271,22 +373,47 @@ function resetDefault() {
<div class="pm-input-group"> <div class="pm-input-group">
<label class="pm-input-label">Key</label> <label class="pm-input-label">Key</label>
<input class="pm-input pm-key-input" <input class="pm-input pm-key-input"
:value="t.key" ref="keyInputRefs"
@input="updateKey(t, ($event.target as HTMLInputElement).value)" v-model="getDraft(t).key"
@blur="commitKey(t)"
@keydown.enter.prevent="commitKey(t)"
placeholder="提示词 Key" placeholder="提示词 Key"
/> />
</div> </div>
<div class="pm-input-group"> <div class="pm-input-group">
<label class="pm-input-label">Translation</label> <div class="pm-input-label-row">
<label class="pm-input-label">Translation</label>
<span v-if="translateErrors[getTagId(t)]" class="pm-input-error" :title="translateErrors[getTagId(t)]">
{{ translateErrors[getTagId(t)] }}
</span>
</div>
<input class="pm-input pm-trans-input" <input class="pm-input pm-trans-input"
:value="displayTranslation(t)" v-model="getDraft(t).translation"
@input="updateTrans(t, ($event.target as HTMLInputElement).value)" @blur="commitTrans(t)"
@keydown.enter.prevent="commitTrans(t)"
placeholder="翻译内容" placeholder="翻译内容"
/> />
</div> </div>
</div> </div>
<div class="pm-tag-actions"> <div class="pm-tag-actions">
<button
class="pm-icon-btn pm-btn-translate"
:class="{ loading: translating[getTagId(t)] }"
:disabled="translating[getTagId(t)]"
@click="translateTag(t)"
title="自动翻译当前提示词"
>
<svg v-if="!translating[getTagId(t)]" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m5 8 6 6"></path>
<path d="m4 14 6-6 2-3"></path>
<path d="M2 5h12"></path>
<path d="M7 2h1"></path>
<path d="m22 22-5-10-5 10"></path>
<path d="M14 18h6"></path>
</svg>
<span v-else class="pm-mini-spinner"></span>
</button>
<button class="pm-icon-btn" @click="toggleHidden(t)" :title="t.hidden ? '显示' : '隐藏'"> <button class="pm-icon-btn" @click="toggleHidden(t)" :title="t.hidden ? '显示' : '隐藏'">
<svg v-if="!t.hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg v-if="!t.hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
@@ -698,6 +825,24 @@ function resetDefault() {
text-transform: uppercase; text-transform: uppercase;
} }
.pm-input-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 15px;
}
.pm-input-error {
color: var(--color-error);
font-size: 11px;
font-weight: 500;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pm-input { .pm-input {
width: 100%; width: 100%;
padding: 8px 12px; padding: 8px 12px;
@@ -741,6 +886,35 @@ function resetDefault() {
border-color: var(--color-border); border-color: var(--color-border);
} }
.pm-icon-btn:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.pm-btn-translate {
color: var(--color-accent);
}
.pm-btn-translate:hover:not(:disabled),
.pm-btn-translate.loading {
background-color: var(--color-accent-light);
border-color: var(--color-accent);
color: var(--color-accent);
}
.pm-mini-spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: pm-spin 0.8s linear infinite;
}
@keyframes pm-spin {
to { transform: rotate(360deg); }
}
.pm-btn-delete:hover { .pm-btn-delete:hover {
background-color: #fef2f2; background-color: #fef2f2;
color: var(--color-error); color: var(--color-error);
+18 -3
View File
@@ -275,8 +275,15 @@ export const usePromptStore = defineStore('promptStore', {
addTag(groupId: string, key = 'new_tag') { addTag(groupId: string, key = 'new_tag') {
const grp = this.findGroupById(groupId); const grp = this.findGroupById(groupId);
if (!grp) return; if (!grp) return;
const existing = new Set(grp.tags.map((t) => t.key));
let nextKey = key;
let suffix = 2;
while (existing.has(nextKey)) {
nextKey = `${key}_${suffix}`;
suffix++;
}
// 新增提示词插入到列表顶部,便于用户立即编辑 // 新增提示词插入到列表顶部,便于用户立即编辑
grp.tags.unshift({ key, translation: { en: key, [this.selectedLang]: key } }); grp.tags.unshift({ key: nextKey, translation: { en: nextKey, [this.selectedLang]: nextKey } });
rebuildTagIndex(this.dataset); rebuildTagIndex(this.dataset);
}, },
removeTag(groupId: string, key: string) { removeTag(groupId: string, key: string) {
@@ -290,9 +297,17 @@ export const usePromptStore = defineStore('promptStore', {
if (!grp) return; if (!grp) return;
const tag = grp.tags.find((t) => t.key === oldKey); const tag = grp.tags.find((t) => t.key === oldKey);
if (!tag) return; if (!tag) return;
tag.key = newKey; const baseKey = newKey.trim() || oldKey;
const existing = new Set(grp.tags.filter((t) => t !== tag).map((t) => t.key));
let nextKey = baseKey;
let suffix = 2;
while (existing.has(nextKey)) {
nextKey = `${baseKey}_${suffix}`;
suffix++;
}
tag.key = nextKey;
if (!tag.translation) tag.translation = {}; if (!tag.translation) tag.translation = {};
tag.translation.en = newKey; tag.translation.en = nextKey;
rebuildTagIndex(this.dataset); rebuildTagIndex(this.dataset);
}, },
setTranslation(groupId: string, key: string, lang: LangCode, val: string) { setTranslation(groupId: string, key: string, lang: LangCode, val: string) {