refactor: 优化代码结构
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppearanceStore } from '../../stores/appearance.store';
|
||||
import { useUiNotificationsStore } from '../../stores/uiNotifications.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const { t } = useI18n();
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const notificationsStore = useUiNotificationsStore();
|
||||
const {
|
||||
terminalBackgroundImage,
|
||||
isTerminalBackgroundEnabled,
|
||||
currentTerminalBackgroundOverlayOpacity,
|
||||
} = storeToRefs(appearanceStore);
|
||||
|
||||
const localTerminalBackgroundEnabled = ref(true);
|
||||
const editableTerminalBackgroundOverlayOpacity = ref(0.5);
|
||||
|
||||
const terminalBgFileInput = ref<HTMLInputElement | null>(null);
|
||||
const uploadError = ref<string | null>(null);
|
||||
|
||||
const initializeEditableState = () => {
|
||||
localTerminalBackgroundEnabled.value = isTerminalBackgroundEnabled.value;
|
||||
editableTerminalBackgroundOverlayOpacity.value = currentTerminalBackgroundOverlayOpacity.value;
|
||||
uploadError.value = null;
|
||||
};
|
||||
|
||||
onMounted(initializeEditableState);
|
||||
|
||||
watch(isTerminalBackgroundEnabled, (newValue) => {
|
||||
if (localTerminalBackgroundEnabled.value !== newValue) {
|
||||
localTerminalBackgroundEnabled.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentTerminalBackgroundOverlayOpacity, (newValue) => {
|
||||
if (editableTerminalBackgroundOverlayOpacity.value !== newValue) {
|
||||
editableTerminalBackgroundOverlayOpacity.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
const handleTriggerTerminalBgUpload = () => {
|
||||
uploadError.value = null;
|
||||
terminalBgFileInput.value?.click();
|
||||
};
|
||||
|
||||
|
||||
const handleTerminalBgUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
try {
|
||||
await appearanceStore.uploadTerminalBackground(file);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.terminalBgUploadSuccess') });
|
||||
input.value = '';
|
||||
} catch (error: any) {
|
||||
const determinedErrorMessage = error.message || t('styleCustomizer.uploadFailed');
|
||||
uploadError.value = determinedErrorMessage;
|
||||
notificationsStore.addNotification({ type: 'error', message: determinedErrorMessage }); // 显示错误通知
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTerminalBg = async () => {
|
||||
try {
|
||||
await appearanceStore.removeTerminalBackground();
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.terminalBgRemoved') });
|
||||
} catch (error: any) {
|
||||
console.error("移除终端背景失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.removeBgFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
// 处理终端背景启用/禁用切换
|
||||
const handleToggleTerminalBackground = async () => {
|
||||
const newValue = !localTerminalBackgroundEnabled.value; // 先计算新值
|
||||
localTerminalBackgroundEnabled.value = newValue; // 立即更新本地 UI
|
||||
try {
|
||||
await appearanceStore.setTerminalBackgroundEnabled(newValue);
|
||||
// 成功后不需要提示,UI 已更新
|
||||
} catch (error: any) {
|
||||
console.error("更新终端背景启用状态失败:", error);
|
||||
// 失败时回滚本地状态
|
||||
localTerminalBackgroundEnabled.value = !newValue;
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorToggleTerminalBg', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
// 保存终端背景蒙版透明度
|
||||
const handleSaveTerminalBackgroundOverlayOpacity = async () => {
|
||||
try {
|
||||
const opacity = Number(editableTerminalBackgroundOverlayOpacity.value);
|
||||
if (isNaN(opacity) || opacity < 0 || opacity > 1) {
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorInvalidOpacityValue') });
|
||||
return;
|
||||
}
|
||||
await appearanceStore.setTerminalBackgroundOverlayOpacity(opacity);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.terminalBgOverlayOpacitySaved') });
|
||||
} catch (error: any) {
|
||||
console.error("保存终端背景蒙版透明度失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.terminalBgOverlayOpacitySaveFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h3 class="mt-0 border-b border-border pb-2 mb-4 text-lg font-semibold text-foreground">{{ t('styleCustomizer.backgroundSettings') }}</h3>
|
||||
|
||||
|
||||
<hr class="my-4 md:my-8 border-border">
|
||||
|
||||
<!-- 终端背景 -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="m-0 text-base font-semibold text-foreground">{{ t('styleCustomizer.terminalBackground') }}</h4>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="handleToggleTerminalBackground"
|
||||
:class="[
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary',
|
||||
localTerminalBackgroundEnabled ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'
|
||||
]"
|
||||
role="switch"
|
||||
:aria-checked="localTerminalBackgroundEnabled"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
localTerminalBackgroundEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="localTerminalBackgroundEnabled">
|
||||
<div class="w-full h-[100px] md:h-[150px] border border-dashed border-border mb-2 flex justify-center items-center text-text-secondary bg-cover bg-center bg-no-repeat rounded bg-header relative overflow-hidden" :style="{ backgroundImage: terminalBackgroundImage ? `url(${terminalBackgroundImage})` : 'none' }">
|
||||
<!-- 实时预览蒙版 -->
|
||||
<div
|
||||
v-if="terminalBackgroundImage"
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: `rgba(0, 0, 0, ${editableTerminalBackgroundOverlayOpacity})` }"
|
||||
></div>
|
||||
<span v-if="!terminalBackgroundImage" class="bg-white/80 px-3 py-1.5 rounded text-sm font-medium text-foreground shadow-sm relative z-10">{{ t('styleCustomizer.noBackground') }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4 flex-wrap items-center">
|
||||
<button @click="handleTriggerTerminalBgUpload" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed">{{ t('styleCustomizer.uploadTerminalBg') }}</button>
|
||||
<button @click="handleRemoveTerminalBg" :disabled="!terminalBackgroundImage" class="px-3 py-1.5 text-sm border rounded transition duration-200 ease-in-out whitespace-nowrap bg-error/10 text-error border-error/30 hover:bg-error/20 disabled:opacity-60 disabled:cursor-not-allowed">{{ t('styleCustomizer.removeTerminalBg') }}</button>
|
||||
<input type="file" ref="terminalBgFileInput" @change="handleTerminalBgUpload" accept="image/*" class="hidden" />
|
||||
</div>
|
||||
|
||||
<!-- 终端背景蒙版透明度控制 -->
|
||||
<div class="mt-4 pt-4 border-t border-border/50">
|
||||
<label for="terminalBgOverlayOpacity" class="block text-sm font-medium text-foreground mb-1">{{ t('styleCustomizer.terminalBgOverlayOpacity', '终端背景蒙版透明度:') }}</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
id="terminalBgOverlayOpacity"
|
||||
v-model.number="editableTerminalBackgroundOverlayOpacity"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="w-full cursor-pointer accent-primary"
|
||||
/>
|
||||
<span class="text-sm text-foreground min-w-[3em] text-right">{{ editableTerminalBackgroundOverlayOpacity.toFixed(2) }}</span>
|
||||
<button @click="handleSaveTerminalBackgroundOverlayOpacity" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-4 text-center text-text-secondary italic border border-dashed border-border/50 rounded-md">
|
||||
{{ t('styleCustomizer.terminalBgDisabled', '终端背景功能已禁用。') }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppearanceStore } from '../../stores/appearance.store';
|
||||
import { useUiNotificationsStore } from '../../stores/uiNotifications.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const { t } = useI18n();
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const notificationsStore = useUiNotificationsStore();
|
||||
const {
|
||||
appearanceSettings, // for watcher
|
||||
currentEditorFontSize,
|
||||
currentEditorFontFamily,
|
||||
} = storeToRefs(appearanceStore);
|
||||
|
||||
const editableEditorFontSize = ref(14);
|
||||
const editableEditorFontFamily = ref('');
|
||||
|
||||
const initializeEditableState = () => {
|
||||
editableEditorFontSize.value = currentEditorFontSize.value;
|
||||
editableEditorFontFamily.value = currentEditorFontFamily.value;
|
||||
};
|
||||
|
||||
onMounted(initializeEditableState);
|
||||
|
||||
watch(
|
||||
() => appearanceSettings.value,
|
||||
(newSettings, oldSettings) => {
|
||||
// Check if the specific properties we care about have changed
|
||||
const fontSizeChanged = newSettings?.editorFontSize !== oldSettings?.editorFontSize;
|
||||
const fontFamilyChanged = newSettings?.editorFontFamily !== oldSettings?.editorFontFamily;
|
||||
|
||||
if (fontSizeChanged) {
|
||||
editableEditorFontSize.value = newSettings?.editorFontSize || 14;
|
||||
}
|
||||
if (fontFamilyChanged) {
|
||||
editableEditorFontFamily.value = newSettings?.editorFontFamily || 'Consolas, "Noto Sans SC", "Microsoft YaHei"';
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 保存编辑器字体大小
|
||||
const handleSaveEditorFontSize = async () => {
|
||||
try {
|
||||
const size = Number(editableEditorFontSize.value);
|
||||
if (isNaN(size) || size <= 0) {
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorInvalidEditorFontSize') });
|
||||
return;
|
||||
}
|
||||
await appearanceStore.setEditorFontSize(size);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.editorFontSizeSaved') });
|
||||
} catch (error: any) {
|
||||
console.error("保存编辑器字体大小失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.editorFontSizeSaveFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
// 保存编辑器字体家族
|
||||
const handleSaveEditorFontFamily = async () => {
|
||||
try {
|
||||
await appearanceStore.setEditorFontFamily(editableEditorFontFamily.value);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.editorFontFamilySaved') });
|
||||
} catch (error: any) {
|
||||
console.error("保存编辑器字体失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.editorFontFamilySaveFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h3 class="mt-0 border-b border-border pb-2 mb-4 text-lg font-semibold text-foreground">{{ t('styleCustomizer.otherSettings') }}</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[auto_1fr_auto] items-start md:items-center gap-2 md:gap-3 mb-3">
|
||||
<label for="editorFontSizeOther" class="text-left text-foreground text-sm font-medium overflow-hidden text-ellipsis block w-full mb-1 md:mb-0">{{ t('styleCustomizer.editorFontSize') }}:</label>
|
||||
<input type="number" id="editorFontSizeOther" v-model.number="editableEditorFontSize" class="border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground max-w-[100px] justify-self-start box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" min="1" />
|
||||
<button @click="handleSaveEditorFontSize" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap justify-self-start mt-1 md:mt-0">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 md:my-6">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[auto_1fr_auto] items-start md:items-center gap-2 md:gap-3 mb-3">
|
||||
<label for="editorFontFamilyOther" class="text-left text-foreground text-sm font-medium overflow-hidden text-ellipsis block w-full mb-1 md:mb-0">{{ t('styleCustomizer.editorFontFamily') }}:</label>
|
||||
<input type="text" id="editorFontFamilyOther" v-model="editableEditorFontFamily" class="border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary">
|
||||
<button @click="handleSaveEditorFontFamily" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap justify-self-start mt-1 md:mt-0">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,632 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppearanceStore } from '../../stores/appearance.store';
|
||||
import { useUiNotificationsStore } from '../../stores/uiNotifications.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { ITheme } from 'xterm';
|
||||
import type { TerminalTheme } from '../../types/terminal-theme.types';
|
||||
import { defaultXtermTheme } from '../../features/appearance/config/default-themes';
|
||||
|
||||
const { t } = useI18n();
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const notificationsStore = useUiNotificationsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
isEditingTheme: boolean;
|
||||
editingTheme: TerminalTheme | null;
|
||||
modalRootRef: HTMLDivElement | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:isEditingTheme', value: boolean): void;
|
||||
(e: 'update:editingTheme', value: TerminalTheme | null): void;
|
||||
}>();
|
||||
|
||||
const {
|
||||
allTerminalThemes,
|
||||
activeTerminalThemeId,
|
||||
currentTerminalFontFamily,
|
||||
currentTerminalFontSize,
|
||||
} = storeToRefs(appearanceStore);
|
||||
|
||||
const editableTerminalFontFamily = ref('');
|
||||
const editableTerminalFontSize = ref(14);
|
||||
const themeSearchTerm = ref('');
|
||||
const saveThemeError = ref<string | null>(null);
|
||||
const editableTerminalThemeString = ref('');
|
||||
const terminalThemeParseError = ref<string | null>(null);
|
||||
const terminalThemePlaceholder = `background: #000000
|
||||
foreground: #ffffff
|
||||
cursor: #ffffff
|
||||
selectionBackground: #555555
|
||||
black: #000000
|
||||
red: #ff0000
|
||||
green: #00ff00
|
||||
yellow: #ffff00
|
||||
blue: #0000ff
|
||||
magenta: #ff00ff
|
||||
cyan: #00ffff
|
||||
white: #ffffff
|
||||
brightBlack: #555555
|
||||
brightRed: #ff5555
|
||||
brightGreen: #55ff55
|
||||
brightYellow: #ffff55
|
||||
brightBlue: #5555ff
|
||||
brightMagenta: #ff55ff
|
||||
brightCyan: #55ffff
|
||||
brightWhite: #ffffff`;
|
||||
// Theme preview refs
|
||||
const originalModalRootBackgroundColor = ref<string | null>(null);
|
||||
const originalModalContentOpacity = ref<string | null>(null);
|
||||
const originalModalRootTransition = ref<string | null>(null);
|
||||
const originalModalContentTransition = ref<string | null>(null);
|
||||
|
||||
const initializeEditableState = () => {
|
||||
editableTerminalFontFamily.value = currentTerminalFontFamily.value;
|
||||
editableTerminalFontSize.value = currentTerminalFontSize.value;
|
||||
saveThemeError.value = null;
|
||||
terminalThemeParseError.value = null;
|
||||
};
|
||||
|
||||
// Watch for external changes to current font settings
|
||||
watch(currentTerminalFontFamily, (newValue) => {
|
||||
if (!props.isEditingTheme) { // Only update if not actively editing a theme (to avoid overriding user input during theme edit)
|
||||
editableTerminalFontFamily.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentTerminalFontSize, (newValue) => {
|
||||
if (!props.isEditingTheme) {
|
||||
editableTerminalFontSize.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on mount and when relevant props change
|
||||
watch(() => [currentTerminalFontFamily.value, currentTerminalFontSize.value], () => {
|
||||
// Re-initialize only if not in the middle of editing a theme,
|
||||
// as editing a theme might involve temporary font changes or different contexts.
|
||||
if (!props.isEditingTheme) {
|
||||
initializeEditableState();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
|
||||
// Methods
|
||||
const handleSaveTerminalFont = async () => {
|
||||
try {
|
||||
await appearanceStore.setTerminalFontFamily(editableTerminalFontFamily.value);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.terminalFontSaved') });
|
||||
} catch (error: any) {
|
||||
console.error("保存终端字体失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.terminalFontSaveFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTerminalFontSize = async () => {
|
||||
try {
|
||||
const size = Number(editableTerminalFontSize.value);
|
||||
if (isNaN(size) || size <= 0) {
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorInvalidFontSize') });
|
||||
return;
|
||||
}
|
||||
await appearanceStore.setTerminalFontSize(size);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.terminalFontSizeSaved') });
|
||||
} catch (error: any) {
|
||||
console.error("保存终端字体大小失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.terminalFontSizeSaveFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyTheme = async (theme: TerminalTheme) => {
|
||||
if (!theme._id) return;
|
||||
const themeIdNum = parseInt(theme._id, 10);
|
||||
if (isNaN(themeIdNum)) {
|
||||
console.error(`无效的主题 ID 格式: ${theme._id}`);
|
||||
return;
|
||||
}
|
||||
if (themeIdNum === activeTerminalThemeId.value) return;
|
||||
try {
|
||||
await appearanceStore.setActiveTerminalTheme(theme._id);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.setActiveThemeSuccess', { themeName: theme.name }) });
|
||||
} catch (error: any) {
|
||||
console.error("应用终端主题失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.setActiveThemeFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewTheme = () => {
|
||||
saveThemeError.value = null;
|
||||
terminalThemeParseError.value = null;
|
||||
const newTheme: TerminalTheme = {
|
||||
_id: undefined,
|
||||
name: t('styleCustomizer.newThemeDefaultName'),
|
||||
themeData: JSON.parse(JSON.stringify(defaultXtermTheme)),
|
||||
isPreset: false,
|
||||
};
|
||||
emit('update:editingTheme', newTheme);
|
||||
try {
|
||||
const themeObject = newTheme.themeData;
|
||||
if (themeObject && typeof themeObject === 'object' && Object.keys(themeObject).length > 0) {
|
||||
const lines = Object.entries(themeObject).map(([key, value]) => `${key}: ${value}`);
|
||||
editableTerminalThemeString.value = lines.join('\n');
|
||||
} else {
|
||||
editableTerminalThemeString.value = '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("格式化新终端主题字符串失败:", e);
|
||||
editableTerminalThemeString.value = '';
|
||||
}
|
||||
emit('update:isEditingTheme', true);
|
||||
};
|
||||
|
||||
const handleEditTheme = async (theme: TerminalTheme) => {
|
||||
saveThemeError.value = null;
|
||||
terminalThemeParseError.value = null;
|
||||
if (!theme._id) {
|
||||
console.error("尝试编辑没有 ID 的主题:", theme);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.errorEditThemeNoId') });
|
||||
return;
|
||||
}
|
||||
|
||||
let themeDataToEdit: ITheme | null = null;
|
||||
let themeNameToEdit = theme.name;
|
||||
let themeIdToEdit: string | undefined = theme._id;
|
||||
|
||||
try {
|
||||
themeDataToEdit = await appearanceStore.loadTerminalThemeData(theme._id);
|
||||
if (!themeDataToEdit) {
|
||||
throw new Error(t('styleCustomizer.errorLoadThemeDataFailed'));
|
||||
}
|
||||
|
||||
if (theme.isPreset) {
|
||||
themeNameToEdit = `${theme.name} (Copy)`;
|
||||
themeIdToEdit = undefined;
|
||||
}
|
||||
|
||||
const themeToEdit: TerminalTheme = {
|
||||
_id: themeIdToEdit,
|
||||
name: themeNameToEdit,
|
||||
themeData: JSON.parse(JSON.stringify(themeDataToEdit)),
|
||||
isPreset: false,
|
||||
};
|
||||
emit('update:editingTheme', themeToEdit);
|
||||
|
||||
try {
|
||||
const themeObject = themeToEdit.themeData;
|
||||
if (themeObject && typeof themeObject === 'object' && Object.keys(themeObject).length > 0) {
|
||||
const lines = Object.entries(themeObject).map(([key, value]) => `${key}: ${value}`);
|
||||
editableTerminalThemeString.value = lines.join('\n');
|
||||
} else {
|
||||
editableTerminalThemeString.value = '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("格式化编辑终端主题字符串失败:", e);
|
||||
editableTerminalThemeString.value = '';
|
||||
}
|
||||
emit('update:isEditingTheme', true);
|
||||
} catch (error: any) {
|
||||
console.error("编辑主题失败 (加载数据时):", error);
|
||||
saveThemeError.value = error.message || t('styleCustomizer.errorEditThemeFailed');
|
||||
emit('update:isEditingTheme', false);
|
||||
emit('update:editingTheme', null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEditingTheme = async () => {
|
||||
if (!props.editingTheme || !props.editingTheme.name) {
|
||||
saveThemeError.value = t('styleCustomizer.errorThemeNameRequired');
|
||||
return;
|
||||
}
|
||||
handleTerminalThemeStringChange();
|
||||
if (terminalThemeParseError.value) {
|
||||
saveThemeError.value = t('styleCustomizer.errorFixJsonBeforeSave');
|
||||
return;
|
||||
}
|
||||
|
||||
saveThemeError.value = null;
|
||||
try {
|
||||
if (!props.editingTheme) return; // Should not happen due to above check
|
||||
const currentThemeData = props.editingTheme.themeData;
|
||||
|
||||
if (props.editingTheme._id) {
|
||||
const updateDto = { name: props.editingTheme.name, themeData: currentThemeData };
|
||||
await appearanceStore.updateTerminalTheme(
|
||||
props.editingTheme._id,
|
||||
updateDto.name,
|
||||
updateDto.themeData
|
||||
);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.themeUpdatedSuccess') });
|
||||
} else {
|
||||
const createDto = { name: props.editingTheme.name, themeData: currentThemeData };
|
||||
await appearanceStore.createTerminalTheme(
|
||||
createDto.name,
|
||||
createDto.themeData
|
||||
);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.themeCreatedSuccess') });
|
||||
}
|
||||
emit('update:isEditingTheme', false);
|
||||
emit('update:editingTheme', null);
|
||||
editableTerminalThemeString.value = '';
|
||||
terminalThemeParseError.value = null;
|
||||
} catch (error: any) {
|
||||
console.error("保存终端主题失败:", error);
|
||||
saveThemeError.value = error.message || t('styleCustomizer.themeSaveFailed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEditingTheme = () => {
|
||||
emit('update:isEditingTheme', false);
|
||||
emit('update:editingTheme', null);
|
||||
saveThemeError.value = null;
|
||||
terminalThemeParseError.value = null;
|
||||
editableTerminalThemeString.value = '';
|
||||
};
|
||||
|
||||
const handleTerminalThemeStringChange = () => {
|
||||
terminalThemeParseError.value = null;
|
||||
if (!props.editingTheme) return;
|
||||
|
||||
let inputText = editableTerminalThemeString.value.trim();
|
||||
if (!inputText) {
|
||||
const updatedTheme = { ...props.editingTheme, themeData: {} };
|
||||
emit('update:editingTheme', updatedTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
let jsonStringToParse = inputText
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && line.includes(':'))
|
||||
.map(line => {
|
||||
const parts = line.split(/:(.*)/s);
|
||||
if (parts.length < 2) return null;
|
||||
let key = parts[0].trim();
|
||||
let value = parts[1].trim();
|
||||
if (key.startsWith('"') && key.endsWith('"')) key = key.slice(1, -1);
|
||||
if (key.startsWith("'") && key.endsWith("'")) key = key.slice(1, -1);
|
||||
key = JSON.stringify(key);
|
||||
if (value.endsWith(',')) value = value.slice(0, -1).trim();
|
||||
let originalValue = value;
|
||||
if (value.startsWith('"') && value.endsWith('"')) originalValue = value.slice(1, -1);
|
||||
else if (value.startsWith("'") && value.endsWith("'")) originalValue = value.slice(1, -1);
|
||||
if (isNaN(Number(originalValue)) && originalValue !== 'true' && originalValue !== 'false' && originalValue !== 'null') {
|
||||
value = JSON.stringify(originalValue);
|
||||
} else {
|
||||
value = originalValue;
|
||||
}
|
||||
return ` ${key}: ${value}`;
|
||||
})
|
||||
.filter(line => line !== null)
|
||||
.join(',\n');
|
||||
|
||||
const fullJsonString = `{\n${jsonStringToParse}\n}`;
|
||||
|
||||
try {
|
||||
const parsedThemeData = JSON.parse(fullJsonString);
|
||||
if (typeof parsedThemeData !== 'object' || parsedThemeData === null || Array.isArray(parsedThemeData)) {
|
||||
throw new Error(t('styleCustomizer.errorInvalidJsonObject'));
|
||||
}
|
||||
const updatedTheme = { ...props.editingTheme, themeData: parsedThemeData };
|
||||
emit('update:editingTheme', updatedTheme);
|
||||
} catch (error: any) {
|
||||
console.error('解析终端主题配置失败:', error);
|
||||
let errorMessage = error.message || t('styleCustomizer.errorInvalidJsonConfig');
|
||||
if (error instanceof SyntaxError) {
|
||||
errorMessage = `${t('styleCustomizer.errorJsonSyntax')}: ${error.message}`;
|
||||
}
|
||||
terminalThemeParseError.value = errorMessage;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTheme = async (theme: TerminalTheme) => {
|
||||
if (theme.isPreset || !theme._id) return;
|
||||
try {
|
||||
await appearanceStore.deleteTerminalTheme(theme._id);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.themeDeletedSuccess') });
|
||||
} catch (error: any) {
|
||||
console.error("删除终端主题失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.themeDeleteFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
const formatXtermLabel = (key: keyof ITheme): string => {
|
||||
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase());
|
||||
};
|
||||
|
||||
const handleFocusAndSelect = (event: FocusEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
target.select();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewButtonMouseDown = async (event: MouseEvent, themeToPreview: TerminalTheme) => {
|
||||
event.preventDefault();
|
||||
if (props.modalRootRef && themeToPreview._id) {
|
||||
const modalContentElement = props.modalRootRef.firstElementChild as HTMLElement;
|
||||
try {
|
||||
const themeData = await appearanceStore.loadTerminalThemeData(themeToPreview._id);
|
||||
if (!themeData) {
|
||||
console.error('Preview failed: Could not load theme data for', themeToPreview.name);
|
||||
return;
|
||||
}
|
||||
appearanceStore.startTerminalThemePreview(themeData);
|
||||
|
||||
if (originalModalRootBackgroundColor.value === null && modalContentElement) {
|
||||
originalModalRootBackgroundColor.value = window.getComputedStyle(props.modalRootRef).backgroundColor;
|
||||
originalModalRootTransition.value = props.modalRootRef.style.transition;
|
||||
originalModalContentOpacity.value = window.getComputedStyle(modalContentElement).opacity;
|
||||
originalModalContentTransition.value = modalContentElement.style.transition;
|
||||
}
|
||||
|
||||
props.modalRootRef.style.transition = 'background-color 0.05s ease-out, opacity 0.05s ease-out';
|
||||
props.modalRootRef.style.backgroundColor = 'transparent';
|
||||
if (modalContentElement) {
|
||||
modalContentElement.style.transition = 'opacity 0.05s ease-out';
|
||||
modalContentElement.style.opacity = '0';
|
||||
}
|
||||
|
||||
const restoreOnMouseUp = () => {
|
||||
appearanceStore.stopTerminalThemePreview();
|
||||
if (props.modalRootRef) {
|
||||
props.modalRootRef.style.backgroundColor = originalModalRootBackgroundColor.value || '';
|
||||
props.modalRootRef.style.transition = originalModalRootTransition.value || '';
|
||||
}
|
||||
if (modalContentElement) {
|
||||
modalContentElement.style.opacity = originalModalContentOpacity.value || '1';
|
||||
modalContentElement.style.transition = originalModalContentTransition.value || '';
|
||||
}
|
||||
originalModalRootBackgroundColor.value = null;
|
||||
originalModalContentOpacity.value = null;
|
||||
originalModalRootTransition.value = null;
|
||||
originalModalContentTransition.value = null;
|
||||
document.removeEventListener('mouseup', restoreOnMouseUp);
|
||||
const currentTargetButton = event.currentTarget as HTMLElement | null;
|
||||
if (currentTargetButton) {
|
||||
currentTargetButton.removeEventListener('mouseleave', restoreOnMouseUp);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mouseup', restoreOnMouseUp, { once: true });
|
||||
const currentTargetButton = event.currentTarget as HTMLElement | null;
|
||||
if (currentTargetButton) {
|
||||
currentTargetButton.addEventListener('mouseleave', restoreOnMouseUp, { once: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during theme preview:', error);
|
||||
appearanceStore.stopTerminalThemePreview();
|
||||
if (props.modalRootRef && modalContentElement) {
|
||||
props.modalRootRef.style.backgroundColor = originalModalRootBackgroundColor.value || '';
|
||||
props.modalRootRef.style.transition = originalModalRootTransition.value || '';
|
||||
modalContentElement.style.opacity = originalModalContentOpacity.value || '1';
|
||||
modalContentElement.style.transition = originalModalContentTransition.value || '';
|
||||
originalModalRootBackgroundColor.value = null;
|
||||
originalModalContentOpacity.value = null;
|
||||
originalModalRootTransition.value = null;
|
||||
originalModalContentTransition.value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Computed Properties
|
||||
const activeThemeName = computed(() => {
|
||||
const currentIdNum = activeTerminalThemeId.value;
|
||||
if (currentIdNum === null || currentIdNum === undefined) {
|
||||
return t('styleCustomizer.noThemeSelected');
|
||||
}
|
||||
const theme = allTerminalThemes.value.find((t: TerminalTheme) => t._id === currentIdNum.toString());
|
||||
return theme ? theme.name : t('styleCustomizer.unknownTheme');
|
||||
});
|
||||
|
||||
const filteredAndSortedThemes = computed(() => {
|
||||
const searchTerm = themeSearchTerm.value.toLowerCase().trim();
|
||||
let themes = [...allTerminalThemes.value];
|
||||
if (searchTerm) {
|
||||
themes = themes.filter((theme: TerminalTheme) => theme.name.toLowerCase().includes(searchTerm));
|
||||
}
|
||||
themes.sort((a: TerminalTheme, b: TerminalTheme) => a.name.localeCompare(b.name));
|
||||
return themes;
|
||||
});
|
||||
|
||||
// Watchers
|
||||
watch(() => props.editingTheme?.themeData, (newThemeData) => {
|
||||
if (newThemeData && (document.activeElement?.id !== 'terminalThemeTextarea' || terminalThemeParseError.value)) {
|
||||
try {
|
||||
let newStringValue = '';
|
||||
if (typeof newThemeData === 'object' && Object.keys(newThemeData).length > 0) {
|
||||
const lines = Object.entries(newThemeData).map(([key, value]) => `${key}: ${value}`);
|
||||
newStringValue = lines.join('\n');
|
||||
}
|
||||
if (newStringValue !== editableTerminalThemeString.value) {
|
||||
editableTerminalThemeString.value = newStringValue;
|
||||
}
|
||||
if (terminalThemeParseError.value && document.activeElement?.id !== 'terminalThemeTextarea') {
|
||||
terminalThemeParseError.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("格式化终端主题字符串失败 (watcher):", e);
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
watch(() => props.isEditingTheme, (isEditing) => {
|
||||
if (isEditing && props.editingTheme) {
|
||||
// Sync editableTerminalThemeString when editing starts
|
||||
try {
|
||||
const themeObject = props.editingTheme.themeData;
|
||||
if (themeObject && typeof themeObject === 'object' && Object.keys(themeObject).length > 0) {
|
||||
const lines = Object.entries(themeObject).map(([key, value]) => `${key}: ${value}`);
|
||||
editableTerminalThemeString.value = lines.join('\n');
|
||||
} else {
|
||||
editableTerminalThemeString.value = terminalThemePlaceholder; // Or empty
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("初始化编辑终端主题字符串失败:", e);
|
||||
editableTerminalThemeString.value = terminalThemePlaceholder; // Or empty
|
||||
}
|
||||
terminalThemeParseError.value = null; // Clear parse error when starting to edit
|
||||
} else if (!isEditing) {
|
||||
// Clear fields when not editing
|
||||
editableTerminalThemeString.value = '';
|
||||
terminalThemeParseError.value = null;
|
||||
saveThemeError.value = null;
|
||||
// Re-initialize font settings from store if not editing
|
||||
initializeEditableState();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="!isEditingTheme">
|
||||
<h3 class="mt-0 border-b border-border pb-2 mb-4 text-lg font-semibold text-foreground">{{ t('styleCustomizer.terminalStyles') }}</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[auto_1fr_auto] items-start md:items-center gap-2 md:gap-3 mb-3">
|
||||
<label for="terminalFontFamily" class="text-left text-foreground text-sm font-medium overflow-hidden text-ellipsis block w-full mb-1 md:mb-0">{{ t('styleCustomizer.terminalFontFamily') }}:</label>
|
||||
<input type="text" id="terminalFontFamily" v-model="editableTerminalFontFamily" class="border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" :placeholder="t('styleCustomizer.terminalFontPlaceholder')" />
|
||||
<button @click="handleSaveTerminalFont" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap justify-self-start mt-1 md:mt-0">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary -mt-1 mb-2">{{ t('styleCustomizer.terminalFontDescription') }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[auto_1fr_auto] items-start md:items-center gap-2 md:gap-3 mb-3">
|
||||
<label for="terminalFontSize" class="text-left text-foreground text-sm font-medium overflow-hidden text-ellipsis block w-full mb-1 md:mb-0">{{ t('styleCustomizer.terminalFontSize') }}:</label>
|
||||
<input type="number" id="terminalFontSize" v-model.number="editableTerminalFontSize" class="border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground max-w-[100px] justify-self-start box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" min="1" />
|
||||
<button @click="handleSaveTerminalFontSize" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap justify-self-start mt-1 md:mt-0">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 md:my-6">
|
||||
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-foreground">{{ t('styleCustomizer.terminalThemeSelection') }}</h4>
|
||||
|
||||
<div class="mb-4 py-2 text-sm md:text-[0.95rem] flex flex-col md:flex-row items-start md:items-center gap-1 md:gap-3">
|
||||
<span class="text-text-secondary">{{ t('styleCustomizer.activeTheme') }}:</span>
|
||||
<strong class="text-foreground font-semibold">{{ activeThemeName }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-6 flex gap-2 flex-wrap items-center pb-4 border-b border-dashed border-border">
|
||||
<button @click="handleAddNewTheme" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed">{{ t('styleCustomizer.addNewTheme') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
v-model="themeSearchTerm"
|
||||
:placeholder="t('styleCustomizer.searchThemePlaceholder', '搜索主题名称...')"
|
||||
class="border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="list-none p-0 mt-4 max-h-[200px] md:max-h-[280px] overflow-y-auto border border-border rounded bg-background">
|
||||
<li v-if="filteredAndSortedThemes.length === 0" class="text-center text-text-secondary p-4 italic">
|
||||
{{ t('styleCustomizer.noThemesFound', 'No matching themes found') }}
|
||||
</li>
|
||||
<li v-else v-for="(theme, index) in filteredAndSortedThemes" :key="theme._id"
|
||||
:class="[
|
||||
'block md:grid md:grid-cols-[1fr_auto] items-center px-3 py-2.5 text-sm md:text-[0.95rem] transition-colors duration-200 ease-in-out gap-2',
|
||||
index < filteredAndSortedThemes.length - 1 ? 'border-b border-border' : '',
|
||||
{ 'bg-button text-button-text': theme._id === activeTerminalThemeId?.toString() },
|
||||
{ 'hover:bg-header': theme._id !== activeTerminalThemeId?.toString() }
|
||||
]"
|
||||
>
|
||||
<span class="block md:col-start-1 md:col-end-2 overflow-hidden text-ellipsis whitespace-nowrap mb-2 md:mb-0" :class="theme._id === activeTerminalThemeId?.toString() ? 'font-bold text-button-text' : 'text-foreground'" :title="theme.name">{{ theme.name }}</span>
|
||||
<div class="flex md:col-start-2 md:col-end-3 flex-shrink-0 gap-2 justify-start md:justify-end flex-wrap">
|
||||
<button
|
||||
@click="handleApplyTheme(theme)"
|
||||
:disabled="theme._id === activeTerminalThemeId?.toString()"
|
||||
:title="t('styleCustomizer.applyThemeTooltip', 'Apply this theme')"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
theme._id === activeTerminalThemeId?.toString() ? 'text-button-text border-white/30 bg-white/10 hover:bg-white/20 hover:border-white/50 disabled:opacity-50 disabled:cursor-default disabled:bg-transparent disabled:border-transparent' : 'border-border bg-header text-foreground hover:bg-border hover:border-text-secondary'
|
||||
]"
|
||||
>
|
||||
{{ t('styleCustomizer.applyButton', 'Apply') }}
|
||||
</button>
|
||||
<button
|
||||
@mousedown="(event) => handlePreviewButtonMouseDown(event, theme)"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap',
|
||||
theme._id === activeTerminalThemeId?.toString() ? 'text-button-text border-white/30 bg-white/10 hover:bg-white/20 hover:border-white/50' : 'border-border bg-header text-foreground hover:bg-border hover:border-text-secondary'
|
||||
]"
|
||||
>
|
||||
{{ t('styleCustomizer.previewButton', 'Preview') }}
|
||||
</button>
|
||||
<button @click="handleEditTheme(theme)" :title="theme.isPreset ? t('styleCustomizer.editAsCopy', 'Edit as Copy') : t('common.edit')"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
theme._id === activeTerminalThemeId?.toString() ? 'text-button-text border-white/30 bg-white/10 hover:bg-white/20 hover:border-white/50' : 'border-border bg-header text-foreground hover:bg-border hover:border-text-secondary'
|
||||
]"
|
||||
>{{ t('common.edit') }}</button>
|
||||
<button @click="handleDeleteTheme(theme)" :disabled="theme.isPreset" :title="theme.isPreset ? t('styleCustomizer.cannotDeletePreset', 'Cannot delete preset theme') : t('common.delete')"
|
||||
:class="[
|
||||
'px-3 py-1.5 text-xs md:text-sm border rounded transition-colors duration-200 ease-in-out whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
theme._id === activeTerminalThemeId?.toString() ? 'text-button-text border-white/30 bg-white/10 hover:bg-white/20 hover:border-white/50' : 'bg-error/10 text-error border-error/30 hover:bg-error/20'
|
||||
]"
|
||||
>{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="isEditingTheme && editingTheme">
|
||||
<h3 class="mt-0 border-b border-border pb-2 mb-4 text-lg font-semibold text-foreground">{{ editingTheme._id ? t('styleCustomizer.editThemeTitle') : t('styleCustomizer.newThemeTitle') }}</h3>
|
||||
<p v-if="saveThemeError" class="text-error-text bg-error/10 border border-error/30 px-3 py-2 rounded text-sm mb-3">{{ saveThemeError }}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-[auto_1fr] items-start md:items-center gap-2 mb-2">
|
||||
<label for="editingThemeName" class="text-left text-foreground text-sm font-medium overflow-hidden text-ellipsis block w-full mb-1 md:mb-0">{{ t('styleCustomizer.themeName') }}:</label>
|
||||
<input type="text" id="editingThemeName" v-model="editingTheme.name" required class="border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||
</div>
|
||||
|
||||
<hr class="my-4 md:my-8 border-border">
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-foreground">{{ t('styleCustomizer.terminalThemeColorEditorTitle') }}</h4>
|
||||
|
||||
<div v-for="(value, key) in editingTheme.themeData" :key="key" class="grid grid-cols-1 md:grid-cols-[auto_1fr] items-start md:items-center gap-2 mb-2">
|
||||
<label :for="`xterm-${key}`" class="text-left text-foreground text-sm font-medium overflow-hidden text-ellipsis block w-full mb-1 md:mb-0">{{ formatXtermLabel(key as keyof ITheme) }}:</label>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<input
|
||||
v-if="typeof value === 'string' && value.startsWith('#')"
|
||||
type="color"
|
||||
:id="`xterm-${key}`"
|
||||
v-model="(editingTheme.themeData as any)[key]"
|
||||
class="p-0.5 h-[34px] min-w-[40px] max-w-[50px] rounded border border-border flex-shrink-0 focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
/>
|
||||
<input
|
||||
v-if="typeof value === 'string' && value.startsWith('#')"
|
||||
type="text"
|
||||
:value="(editingTheme.themeData as any)[key]"
|
||||
readonly
|
||||
class="flex-grow min-w-[80px] bg-header cursor-text border border-border px-[0.7rem] py-2 rounded text-sm text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
@focus="handleFocusAndSelect"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
:id="`xterm-${key}`"
|
||||
v-model="(editingTheme.themeData as any)[key]"
|
||||
class="border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 md:my-8 border-border">
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-foreground">{{ t('styleCustomizer.terminalThemeJsonEditorTitle') }}</h4>
|
||||
<p class="text-text-secondary text-sm leading-relaxed mb-3">{{ t('styleCustomizer.terminalThemeJsonEditorDesc') }}</p>
|
||||
<div class="mt-4">
|
||||
<label for="terminalThemeTextarea" class="sr-only">{{ t('styleCustomizer.terminalThemeJsonEditorTitle') }}</label>
|
||||
<textarea
|
||||
id="terminalThemeTextarea"
|
||||
v-model="editableTerminalThemeString"
|
||||
@blur="handleTerminalThemeStringChange"
|
||||
rows="10"
|
||||
:placeholder="terminalThemePlaceholder"
|
||||
spellcheck="false"
|
||||
class="w-full font-mono text-sm leading-snug border border-border rounded p-3 bg-background text-foreground resize-y min-h-[150px] md:min-h-[200px] box-border whitespace-pre-wrap break-words transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
></textarea>
|
||||
</div>
|
||||
<p v-if="terminalThemeParseError" class="text-error-text bg-error/10 border border-error/30 px-3 py-2 rounded text-sm mt-2">{{ terminalThemeParseError }}</p>
|
||||
<div class="mt-4 flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<button @click="handleCancelEditingTheme" class="px-4 md:px-5 py-2 rounded font-bold border border-border bg-header text-foreground hover:bg-border disabled:opacity-60 disabled:cursor-not-allowed text-sm md:text-base">{{ t('common.cancel') }}</button>
|
||||
<button @click="handleSaveEditingTheme" class="px-4 md:px-5 py-2 rounded font-bold border border-button bg-button text-button-text hover:bg-button-hover hover:border-button-hover disabled:opacity-60 disabled:cursor-not-allowed text-sm md:text-base">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppearanceStore } from '../../stores/appearance.store';
|
||||
import { useUiNotificationsStore } from '../../stores/uiNotifications.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { defaultUiTheme } from '../../features/appearance/config/default-themes';
|
||||
import { safeJsonParse } from '../../stores/appearance.store';
|
||||
|
||||
const { t } = useI18n();
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const notificationsStore = useUiNotificationsStore();
|
||||
const { appearanceSettings } = storeToRefs(appearanceStore);
|
||||
|
||||
const editableUiTheme = ref<Record<string, string>>({});
|
||||
const editableUiThemeString = ref('');
|
||||
const themeParseError = ref<string | null>(null);
|
||||
|
||||
// 定义黑暗模式主题变量
|
||||
const darkModeTheme = {
|
||||
'--app-bg-color': '#212529',
|
||||
'--text-color': '#e9ecef',
|
||||
'--text-color-secondary': '#adb5bd',
|
||||
'--border-color': '#495057',
|
||||
'--link-color': '#BB86FC',
|
||||
'--link-hover-color': '#D1A9FF',
|
||||
'--link-active-color': '#A06CD5',
|
||||
'--link-active-bg-color': 'rgba(160, 108, 213, 0.2)',
|
||||
'--nav-item-active-bg-color': 'var(--link-active-bg-color)',
|
||||
'--header-bg-color': '#343a40',
|
||||
'--footer-bg-color': '#343a40',
|
||||
'--button-bg-color': 'var(--link-active-color)',
|
||||
'--button-text-color': '#ffffff',
|
||||
'--button-hover-bg-color': '#8E44AD',
|
||||
'--icon-color': 'var(--text-color-secondary)',
|
||||
'--icon-hover-color': 'var(--link-hover-color)',
|
||||
'--split-line-color': 'var(--border-color)',
|
||||
'--split-line-hover-color': 'var(--border-color)',
|
||||
'--input-focus-border-color': 'var(--link-active-color)',
|
||||
'--input-focus-glow': 'var(--link-active-color)',
|
||||
'--overlay-bg-color': 'rgba(0, 0, 0, 0.8)',
|
||||
'--color-success': '#5cb85c',
|
||||
'--color-error': '#d9534f',
|
||||
'--color-warning': '#f0ad4e',
|
||||
'--font-family-sans-serif': 'sans-serif',
|
||||
'--base-padding': '1rem',
|
||||
'--base-margin': '0.5rem'
|
||||
};
|
||||
|
||||
const initializeEditableState = () => {
|
||||
const userThemeJson = appearanceSettings.value.customUiTheme;
|
||||
const userTheme = safeJsonParse(userThemeJson, {});
|
||||
const mergedTheme = { ...defaultUiTheme, ...userTheme };
|
||||
editableUiTheme.value = JSON.parse(JSON.stringify(mergedTheme));
|
||||
themeParseError.value = null;
|
||||
try {
|
||||
const themeObject = editableUiTheme.value;
|
||||
if (themeObject && typeof themeObject === 'object' && Object.keys(themeObject).length > 0) {
|
||||
const lines = Object.entries(themeObject).map(([key, value]) => `${key}: ${value}`);
|
||||
editableUiThemeString.value = lines.join('\n');
|
||||
} else {
|
||||
editableUiThemeString.value = '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("初始化 UI 主题字符串失败:", e);
|
||||
editableUiThemeString.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(initializeEditableState);
|
||||
|
||||
watch(() => appearanceSettings.value.customUiTheme, () => {
|
||||
console.log('[StyleCustomizerUiTab Watch] customUiTheme changed, re-initializing.');
|
||||
initializeEditableState();
|
||||
}, { deep: true });
|
||||
|
||||
|
||||
const handleSaveUiTheme = async () => {
|
||||
try {
|
||||
await appearanceStore.saveCustomUiTheme(editableUiTheme.value);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.uiThemeSaved') });
|
||||
} catch (error: any) {
|
||||
console.error("保存 UI 主题失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.uiThemeSaveFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetUiTheme = async () => {
|
||||
try {
|
||||
await appearanceStore.resetCustomUiTheme();
|
||||
notificationsStore.addNotification({ type: 'info', message: t('styleCustomizer.uiThemeReset') });
|
||||
} catch (error: any) {
|
||||
console.error("重置 UI 主题失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.uiThemeResetFailed', { message: error.message }) });
|
||||
}
|
||||
};
|
||||
|
||||
const applyDarkMode = async () => {
|
||||
try {
|
||||
editableUiTheme.value = JSON.parse(JSON.stringify(darkModeTheme));
|
||||
await appearanceStore.saveCustomUiTheme(editableUiTheme.value);
|
||||
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.darkModeApplied') });
|
||||
} catch (error: any) {
|
||||
console.error("应用黑暗模式失败:", error);
|
||||
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.darkModeApplyFailed', { message: error.message || '未知错误' }) });
|
||||
}
|
||||
};
|
||||
|
||||
const formattedEditableUiThemeJson = computed(() => {
|
||||
try {
|
||||
const themeObject = editableUiTheme.value;
|
||||
if (!themeObject || typeof themeObject !== 'object' || Object.keys(themeObject).length === 0) {
|
||||
return '';
|
||||
}
|
||||
const lines = Object.entries(themeObject).map(([key, value]) => {
|
||||
return `${key}: ${value}`;
|
||||
});
|
||||
return lines.join('\n');
|
||||
} catch (e) {
|
||||
console.error("序列化可编辑 UI 主题键值对失败:", e);
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(formattedEditableUiThemeJson, (newJson) => {
|
||||
if (document.activeElement?.id !== 'uiThemeTextarea' || themeParseError.value) {
|
||||
editableUiThemeString.value = newJson;
|
||||
if (themeParseError.value && document.activeElement?.id !== 'uiThemeTextarea') {
|
||||
themeParseError.value = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleUiThemeStringChange = () => {
|
||||
themeParseError.value = null;
|
||||
let inputText = editableUiThemeString.value.trim();
|
||||
|
||||
if (!inputText) {
|
||||
editableUiTheme.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
let jsonStringToParse = inputText
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && line.includes(':'))
|
||||
.map(line => {
|
||||
const parts = line.split(/:(.*)/s);
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
let key = parts[0].trim();
|
||||
let value = parts[1].trim();
|
||||
|
||||
if (key.startsWith('"') && key.endsWith('"')) key = key.slice(1, -1);
|
||||
if (key.startsWith("'") && key.endsWith("'")) key = key.slice(1, -1);
|
||||
key = JSON.stringify(key);
|
||||
|
||||
if (value.endsWith(',')) value = value.slice(0, -1).trim();
|
||||
let originalValue = value;
|
||||
if (value.startsWith('"') && value.endsWith('"')) originalValue = value.slice(1, -1);
|
||||
else if (value.startsWith("'") && value.endsWith("'")) originalValue = value.slice(1, -1);
|
||||
|
||||
if (isNaN(Number(originalValue)) && originalValue !== 'true' && originalValue !== 'false' && originalValue !== 'null') {
|
||||
value = JSON.stringify(originalValue);
|
||||
} else {
|
||||
value = originalValue;
|
||||
}
|
||||
return ` ${key}: ${value}`;
|
||||
})
|
||||
.filter(line => line !== null)
|
||||
.join(',\n');
|
||||
|
||||
const fullJsonString = `{\n${jsonStringToParse}\n}`;
|
||||
|
||||
try {
|
||||
const parsedTheme = JSON.parse(fullJsonString);
|
||||
if (typeof parsedTheme !== 'object' || parsedTheme === null || Array.isArray(parsedTheme)) {
|
||||
throw new Error(t('styleCustomizer.errorInvalidJsonObject'));
|
||||
}
|
||||
editableUiTheme.value = parsedTheme;
|
||||
} catch (error: any) {
|
||||
console.error('解析 UI 主题配置失败:', error);
|
||||
let errorMessage = error.message || t('styleCustomizer.errorInvalidJsonConfig');
|
||||
if (error instanceof SyntaxError) {
|
||||
errorMessage = `${t('styleCustomizer.errorJsonSyntax')}: ${error.message}`;
|
||||
}
|
||||
themeParseError.value = errorMessage;
|
||||
}
|
||||
};
|
||||
|
||||
const formatLabel = (key: string): string => {
|
||||
return key
|
||||
.replace(/^--/, '')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase());
|
||||
};
|
||||
|
||||
const handleFocusAndSelect = (event: FocusEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLInputElement) {
|
||||
target.select();
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
handleSaveUiTheme,
|
||||
handleResetUiTheme
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h3 class="mt-0 border-b border-border pb-2 mb-4 text-lg font-semibold text-foreground">{{ t('styleCustomizer.uiStyles') }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-[auto_1fr] items-start md:items-center gap-2 md:gap-3 mb-6">
|
||||
<label class="text-left text-foreground text-sm font-medium mb-1 md:mb-0">{{ t('styleCustomizer.themeModeLabel', '主题模式:') }}</label>
|
||||
<div class="flex gap-2 justify-start flex-wrap">
|
||||
<button @click="handleResetUiTheme" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap">{{ t('styleCustomizer.defaultMode', '默认模式') }}</button>
|
||||
<button @click="applyDarkMode" class="px-3 py-1.5 text-sm border border-border rounded bg-header hover:bg-border transition duration-200 ease-in-out whitespace-nowrap">{{ t('styleCustomizer.darkMode', '黑暗模式') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-text-secondary text-sm leading-relaxed mb-3">{{ t('styleCustomizer.uiDescription') }}</p>
|
||||
<div v-for="(value, key) in editableUiTheme" :key="key" class="grid grid-cols-1 md:grid-cols-[auto_1fr] items-start md:items-center gap-x-3 gap-y-1 mb-3">
|
||||
<label :for="`ui-${key}`" class="text-left text-foreground text-sm font-medium overflow-hidden text-ellipsis block w-full mb-1 md:mb-0">{{ formatLabel(key) }}:</label>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<input
|
||||
v-if="typeof value === 'string' && (value.startsWith('#') || value.startsWith('rgb') || value.startsWith('hsl'))"
|
||||
type="color"
|
||||
:id="`ui-${key}`"
|
||||
v-model="editableUiTheme[key]"
|
||||
class="p-0.5 h-[34px] min-w-[40px] max-w-[50px] rounded border border-border flex-shrink-0 focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
/>
|
||||
<input
|
||||
v-if="typeof value === 'string' && (value.startsWith('#') || value.startsWith('rgb') || value.startsWith('hsl'))"
|
||||
type="text"
|
||||
:value="editableUiTheme[key]"
|
||||
class="flex-grow min-w-[80px] bg-background cursor-text border border-border px-[0.7rem] py-2 rounded text-sm text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
@focus="handleFocusAndSelect"
|
||||
@input="editableUiTheme[key] = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
:id="`ui-${key}`"
|
||||
v-model="editableUiTheme[key]"
|
||||
class="col-span-full border border-border px-[0.7rem] py-2 rounded text-sm bg-background text-foreground w-full box-border transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr style="margin-top: calc(var(--base-padding) * 2); margin-bottom: calc(var(--base-padding) * 2);">
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-foreground">{{ t('styleCustomizer.uiThemeJsonEditorTitle') }}</h4>
|
||||
<p class="text-text-secondary text-sm leading-relaxed mb-3">{{ t('styleCustomizer.uiThemeJsonEditorDesc') }}</p>
|
||||
<div class="mt-4">
|
||||
<label for="uiThemeTextarea" class="sr-only">{{ t('styleCustomizer.uiThemeJsonEditorTitle') }}</label>
|
||||
<textarea
|
||||
id="uiThemeTextarea"
|
||||
v-model="editableUiThemeString"
|
||||
@blur="handleUiThemeStringChange"
|
||||
rows="15"
|
||||
:placeholder="'--app-bg-color: #ffffff\n--text-color: #333333\n...'"
|
||||
spellcheck="false"
|
||||
class="w-full font-mono text-sm leading-snug border border-border rounded p-3 bg-background text-foreground resize-y min-h-[200px] box-border whitespace-pre-wrap break-words transition duration-200 ease-in-out focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
></textarea>
|
||||
</div>
|
||||
<p v-if="themeParseError" class="text-error-text bg-error/10 border border-error/30 px-3 py-2 rounded text-sm mt-2">{{ themeParseError }}</p>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user