feat: 添加自定义终端背景html功能

This commit is contained in:
Baobhan Sith
2025-05-27 09:33:25 +08:00
parent 180a5f6182
commit e11cc66114
9 changed files with 268 additions and 119 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import type { TerminalTheme } from '../types/terminal-theme.types';
import StyleCustomizerUiTab from './style-customizer/StyleCustomizerUiTab.vue';
@@ -42,15 +42,102 @@ const handleResetUiTheme = async () => {
const modalRootRef = ref<HTMLDivElement | null>(null);
const headerRef = ref<HTMLElement | null>(null);
const dialogContentRef = ref<HTMLDivElement | null>(null);
const draggableState = reactive({
isDragging: false,
startX: 0,
startY: 0,
initialLeft: 0,
initialTop: 0,
});
onMounted(() => {
const headerElement = headerRef.value;
const dialogEl = dialogContentRef.value;
const rootEl = modalRootRef.value;
if (!headerElement || !dialogEl || !rootEl) {
// console.warn("Draggable elements not found for StyleCustomizer modal."); // 用于调试的可选日志
return;
}
nextTick(() => {
//确保对话框已渲染且其尺寸可用
if (dialogEl && rootEl) {
dialogEl.style.position = 'absolute'; // 使对话框在rootEl内绝对定位
const rootWidth = rootEl.clientWidth;
const rootHeight = rootEl.clientHeight;
const dialogWidth = dialogEl.offsetWidth;
const dialogHeight = dialogEl.offsetHeight;
// 使对话框居中
dialogEl.style.left = `${Math.max(0, (rootWidth - dialogWidth) / 2)}px`;
dialogEl.style.top = `${Math.max(0, (rootHeight - dialogHeight) / 2)}px`;
}
});
const onMouseDown = (event: MouseEvent) => {
if (!dialogEl) return;
event.preventDefault(); // 防止文本选择等默认行为
draggableState.isDragging = true;
draggableState.startX = event.clientX;
draggableState.startY = event.clientY;
draggableState.initialLeft = dialogEl.offsetLeft;
draggableState.initialTop = dialogEl.offsetTop;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (event: MouseEvent) => {
if (!draggableState.isDragging || !dialogEl || !rootEl) return;
const dx = event.clientX - draggableState.startX;
const dy = event.clientY - draggableState.startY;
let newLeft = draggableState.initialLeft + dx;
let newTop = draggableState.initialTop + dy;
// 边界检查,使对话框保持在 modalRootRef (视口) 内
newLeft = Math.max(0, Math.min(newLeft, rootEl.clientWidth - dialogEl.offsetWidth));
newTop = Math.max(0, Math.min(newTop, rootEl.clientHeight - dialogEl.offsetHeight));
dialogEl.style.left = `${newLeft}px`;
dialogEl.style.top = `${newTop}px`;
};
const onMouseUp = () => {
if (!draggableState.isDragging) return;
draggableState.isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
headerElement.addEventListener('mousedown', onMouseDown);
headerElement.style.cursor = 'move'; // 设置页眉鼠标样式为可拖动
onUnmounted(() => {
if (headerElement) {
headerElement.removeEventListener('mousedown', onMouseDown);
headerElement.style.cursor = ''; // 重置鼠标样式
}
// 清理全局监听器
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
});
</script>
<template>
<div ref="modalRootRef" class="fixed inset-0 bg-black/60 flex justify-center items-center z-[1000] p-2 md:p-4" @click.self="closeCustomizer">
<div class="bg-background text-foreground rounded-lg shadow-lg w-full h-full md:w-[90%] md:max-w-[800px] md:h-[85vh] md:max-h-[700px] flex flex-col overflow-hidden">
<header class="flex justify-between items-center px-4 py-3 border-b border-border bg-header flex-shrink-0">
<div ref="modalRootRef" class="fixed inset-0 z-[1000]" @click.self="closeCustomizer">
<div ref="dialogContentRef" class="bg-background text-foreground rounded-lg shadow-lg w-full h-full md:w-[90%] md:max-w-[800px] md:h-[85vh] md:max-h-[700px] flex flex-col overflow-hidden">
<header ref="headerRef" class="flex justify-between items-center px-4 py-3 border-b border-border bg-header flex-shrink-0">
<h2 class="m-0 text-lg md:text-xl text-foreground">{{ t('styleCustomizer.title') }}</h2>
<button @click="closeCustomizer" class="bg-transparent border-none text-2xl md:text-3xl leading-none cursor-pointer text-text-secondary px-2 py-1 rounded hover:text-foreground hover:bg-black/10">&times;</button>
</header>
+87 -27
View File
@@ -29,6 +29,7 @@ const terminalOuterWrapperRef = ref<HTMLElement | null>(null); // 最外层容
let terminal: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let searchAddon: SearchAddon | null = null; // *** 添加 searchAddon 变量 ***
const customHtmlLayerRef = ref<HTMLElement | null>(null); // Ref for the custom HTML layer
let resizeObserver: ResizeObserver | null = null;
let observedElement: HTMLElement | null = null; // +++ Store the observed element +++
let debounceTimer: number | null = null; // 用于防抖的计时器 ID
@@ -49,6 +50,7 @@ const {
currentTerminalFontSize,
isTerminalBackgroundEnabled,
currentTerminalBackgroundOverlayOpacity, // 获取蒙版透明度
terminalCustomHTML, // 用于自定义终端背景 HTML
} = storeToRefs(appearanceStore);
// --- Settings Store ---
@@ -614,7 +616,28 @@ defineExpose({ write, findNext, findPrevious, clearSearch, clear }); // 暴露 c
const applyTerminalBackground = () => {
// 背景应用到 terminalOuterWrapperRef
if (terminalOuterWrapperRef.value) {
if (!isTerminalBackgroundEnabled.value) {
if (isTerminalBackgroundEnabled.value) {
// 只要启用了背景功能,就应该让 xterm 透明以显示下方内容
nextTick(() => {
if (terminalOuterWrapperRef.value) {
terminalOuterWrapperRef.value.classList.add('has-terminal-background');
if (terminalBackgroundImage.value) {
const backendUrl = import.meta.env.VITE_API_BASE_URL || '';
const imagePath = terminalBackgroundImage.value;
const fullImageUrl = `${backendUrl}${imagePath}`;
terminalOuterWrapperRef.value.style.backgroundImage = `url(${fullImageUrl})`;
terminalOuterWrapperRef.value.style.backgroundSize = 'cover';
terminalOuterWrapperRef.value.style.backgroundPosition = 'center';
terminalOuterWrapperRef.value.style.backgroundRepeat = 'no-repeat';
console.log(`[Terminal ${props.sessionId}] 应用终端背景图片: ${terminalBackgroundImage.value}`);
} else {
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
console.log(`[Terminal ${props.sessionId}] 终端背景功能已启用,但无背景图片,xterm 应透明。`);
}
}
});
} else {
// 背景功能禁用
nextTick(() => {
if (terminalOuterWrapperRef.value) {
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
@@ -622,45 +645,82 @@ const applyTerminalBackground = () => {
}
});
console.log(`[Terminal ${props.sessionId}] 终端背景已禁用,移除背景。`);
return;
}
if (terminalBackgroundImage.value) {
const backendUrl = import.meta.env.VITE_API_BASE_URL || '';
const imagePath = terminalBackgroundImage.value;
const fullImageUrl = `${backendUrl}${imagePath}`;
nextTick(() => {
if (terminalOuterWrapperRef.value) {
terminalOuterWrapperRef.value.style.backgroundImage = `url(${fullImageUrl})`;
terminalOuterWrapperRef.value.style.backgroundSize = 'cover';
terminalOuterWrapperRef.value.style.backgroundPosition = 'center';
terminalOuterWrapperRef.value.style.backgroundRepeat = 'no-repeat';
terminalOuterWrapperRef.value.classList.add('has-terminal-background');
}
});
console.log(`[Terminal ${props.sessionId}] 应用终端背景图片: ${terminalBackgroundImage.value}`);
} else {
nextTick(() => {
if (terminalOuterWrapperRef.value) {
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
terminalOuterWrapperRef.value.classList.remove('has-terminal-background');
}
});
console.log(`[Terminal ${props.sessionId}] 移除终端背景图片。`);
}
}
};
// Function to execute scripts within an element
const executeScriptsInElement = (container: HTMLElement) => {
if (!container) return;
console.log('[Terminal] Attempting to execute scripts in custom HTML container:', container);
const scripts = Array.from(container.getElementsByTagName('script'));
console.log(`[Terminal] Found ${scripts.length} script(s) in custom HTML.`);
scripts.forEach((oldScript, index) => {
console.log(`[Terminal] Processing script #${index + 1}:`, oldScript.outerHTML.substring(0, 100) + '...');
const newScript = document.createElement('script');
// Copy attributes (type, src, async, defer, etc.)
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value);
console.log(`[Terminal] Script #${index + 1}: Copied attribute ${attr.name}="${attr.value}"`);
});
// Copy content for inline scripts
if (oldScript.textContent) {
newScript.textContent = oldScript.textContent;
console.log(`[Terminal] Script #${index + 1}: Copied inline content.`);
}
if (oldScript.parentNode) {
oldScript.parentNode.insertBefore(newScript, oldScript.nextSibling); // Insert new after old
oldScript.parentNode.removeChild(oldScript); // Then remove old
console.log('[Terminal] Script #${index + 1} re-inserted and old one removed.');
} else {
container.appendChild(newScript);
console.warn('[Terminal] Script #${index + 1} had no parent, appended to container directly.');
}
});
console.log('[Terminal] Finished processing scripts in custom HTML.');
};
// Watch for changes in terminalCustomHTML and execute scripts
watch(terminalCustomHTML, (newHtmlContent) => {
// Always operate within nextTick to ensure v-html has updated the DOM
nextTick(() => {
const container = customHtmlLayerRef.value;
if (container) {
if (newHtmlContent) {
console.log('[Terminal] terminalCustomHTML changed, processing new HTML content.');
executeScriptsInElement(container);
} else {
console.log('[Terminal] terminalCustomHTML cleared.');
}
}
});
}, { immediate: true });
</script>
<template>
<div ref="terminalOuterWrapperRef" class="terminal-outer-wrapper">
<!-- 蒙版层 -->
<div
v-if="isTerminalBackgroundEnabled && terminalBackgroundImage"
v-if="isTerminalBackgroundEnabled"
class="terminal-background-overlay"
:style="{ backgroundColor: `rgba(0, 0, 0, ${currentTerminalBackgroundOverlayOpacity})` }"
></div>
<div
ref="customHtmlLayerRef"
v-if="isTerminalBackgroundEnabled && terminalCustomHTML"
class="terminal-custom-html-layer"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none;"
v-html="terminalCustomHTML"
></div>
<!-- xterm 实际挂载点 -->
<div ref="terminalRef" class="terminal-inner-container"></div>
</div>
@@ -12,10 +12,12 @@ const {
terminalBackgroundImage,
isTerminalBackgroundEnabled,
currentTerminalBackgroundOverlayOpacity,
terminalCustomHTML,
} = storeToRefs(appearanceStore);
const localTerminalBackgroundEnabled = ref(true);
const editableTerminalBackgroundOverlayOpacity = ref(0.5);
const localTerminalCustomHTML = ref('');
const terminalBgFileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref<string | null>(null);
@@ -23,6 +25,7 @@ const uploadError = ref<string | null>(null);
const initializeEditableState = () => {
localTerminalBackgroundEnabled.value = isTerminalBackgroundEnabled.value;
editableTerminalBackgroundOverlayOpacity.value = currentTerminalBackgroundOverlayOpacity.value;
localTerminalCustomHTML.value = terminalCustomHTML.value || '';
uploadError.value = null;
};
@@ -40,6 +43,13 @@ watch(currentTerminalBackgroundOverlayOpacity, (newValue) => {
}
});
watch(terminalCustomHTML, (newValue) => {
if (localTerminalCustomHTML.value !== (newValue || '')) {
localTerminalCustomHTML.value = newValue || '';
}
});
const handleTriggerTerminalBgUpload = () => {
uploadError.value = null;
terminalBgFileInput.value?.click();
@@ -104,6 +114,17 @@ const handleSaveTerminalBackgroundOverlayOpacity = async () => {
}
};
const handleSaveCustomHTML = async () => {
try {
await appearanceStore.setTerminalCustomHTML(localTerminalCustomHTML.value);
notificationsStore.addNotification({ type: 'success', message: t('styleCustomizer.customTerminalHTMLSaved') });
} catch (error: any) {
console.error("保存自定义终端 HTML 失败:", error);
notificationsStore.addNotification({ type: 'error', message: t('styleCustomizer.customTerminalHTMLSaveFailed', { message: error.message }) });
}
};
</script>
<template>
@@ -171,6 +192,26 @@ const handleSaveTerminalBackgroundOverlayOpacity = async () => {
<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>
<!-- 自定义终端背景 HTML -->
<div class="mt-4 pt-4 border-t border-border/50">
<label for="terminalCustomHTML" class="block text-sm font-medium text-foreground mb-1">{{ t('styleCustomizer.customTerminalHTML', '自定义终端背景 HTML') }}</label>
<textarea
id="terminalCustomHTML"
v-model="localTerminalCustomHTML"
rows="10"
class="w-full p-2 border border-border rounded bg-input text-foreground focus:ring-primary focus:border-primary"
:placeholder="t('styleCustomizer.customTerminalHTMLPlaceholder', '例如:<h1>Hello</h1>')"
></textarea>
<div class="mt-2 flex justify-end">
<button
@click="handleSaveCustomHTML"
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', '终端背景功能已禁用。') }}
@@ -57,10 +57,6 @@ 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;
@@ -341,73 +337,6 @@ const handleFocusAndSelect = (event: FocusEvent) => {
}
};
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(() => {
@@ -543,15 +472,6 @@ watch(() => props.isEditingTheme, (isEditing) => {
>
{{ 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',
@@ -134,6 +134,9 @@ export const useAppearanceStore = defineStore('appearance', () => {
return typeof opacity === 'number' && opacity >= 0 && opacity <= 1 ? opacity : 0.5; // 默认 0.5
});
// 获取终端自定义 CSS
const terminalCustomHTML = computed(() => appearanceSettings.value.terminal_custom_html ?? null);
// --- Actions ---
/**
@@ -307,6 +310,22 @@ export const useAppearanceStore = defineStore('appearance', () => {
await updateAppearanceSettings({ terminalBackgroundOverlayOpacity: opacity });
}
/**
* 设置终端自定义 HTML
* @param html HTML 字符串,或 null 清除
*/
async function setTerminalCustomHTML(html: string | null) {
try {
await updateAppearanceSettings({ terminal_custom_html: html });
// console.log('[AppearanceStore] Terminal custom HTML updated successfully.');
// 可以在此调用 uiNotifications.store 来显示成功消息
} catch (err: any) {
console.error('设置终端自定义 HTML 失败:', err);
// 可以在此调用 uiNotifications.store 来显示失败消息
throw new Error(err.response?.data?.message || err.message || '设置终端自定义 HTML 失败');
}
}
// --- 终端主题列表管理 Actions ---
/**
@@ -675,11 +694,13 @@ export const useAppearanceStore = defineStore('appearance', () => {
exportTerminalTheme,
uploadPageBackground,
uploadTerminalBackground,
setTerminalBackgroundOverlayOpacity,
setTerminalBackgroundOverlayOpacity,
setTerminalCustomHTML, // 设置终端自定义 HTML
removePageBackground,
removeTerminalBackground,
loadTerminalThemeData,
isTerminalBackgroundEnabled,
terminalCustomHTML, // 获取终端自定义 HTML
startTerminalThemePreview,
stopTerminalThemePreview,
// Visibility control
@@ -15,6 +15,7 @@ export interface AppearanceSettings {
editorFontFamily?: string | null; // Monaco Editor 字体偏好
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
terminal_custom_html?: string | null; // 终端自定义 HTML
}
// 前端用于更新外观设置的数据结构 (对应 API 请求体)