feat: 添加自定义终端背景html功能
This commit is contained in:
@@ -61,6 +61,9 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
|
|||||||
case 'editorFontFamily':
|
case 'editorFontFamily':
|
||||||
settings.editorFontFamily = row.value || null; // 如果为空字符串,则视为 null
|
settings.editorFontFamily = row.value || null; // 如果为空字符串,则视为 null
|
||||||
break;
|
break;
|
||||||
|
case 'terminal_custom_html':
|
||||||
|
settings.terminal_custom_html = row.value;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,13 +78,14 @@ const mapRowsToAppearanceSettings = (rows: DbAppearanceSettingsRow[]): Appearanc
|
|||||||
editorFontFamily: settings.editorFontFamily ?? defaults.editorFontFamily,
|
editorFontFamily: settings.editorFontFamily ?? defaults.editorFontFamily,
|
||||||
terminalBackgroundImage: settings.terminalBackgroundImage ?? defaults.terminalBackgroundImage,
|
terminalBackgroundImage: settings.terminalBackgroundImage ?? defaults.terminalBackgroundImage,
|
||||||
pageBackgroundImage: settings.pageBackgroundImage ?? defaults.pageBackgroundImage,
|
pageBackgroundImage: settings.pageBackgroundImage ?? defaults.pageBackgroundImage,
|
||||||
// 修改:只有当数据库中未找到记录时才使用默认值
|
// 只有当数据库中未找到记录时才使用默认值
|
||||||
terminalBackgroundEnabled: terminalBackgroundEnabledFound
|
terminalBackgroundEnabled: terminalBackgroundEnabledFound
|
||||||
? settings.terminalBackgroundEnabled // 使用数据库找到的值 (true 或 false)
|
? settings.terminalBackgroundEnabled // 使用数据库找到的值 (true 或 false)
|
||||||
: defaults.terminalBackgroundEnabled, // 否则使用默认值 (true)
|
: defaults.terminalBackgroundEnabled, // 否则使用默认值 (true)
|
||||||
terminalBackgroundOverlayOpacity: terminalBackgroundOverlayOpacityFound
|
terminalBackgroundOverlayOpacity: terminalBackgroundOverlayOpacityFound
|
||||||
? settings.terminalBackgroundOverlayOpacity // 使用数据库找到的值
|
? settings.terminalBackgroundOverlayOpacity // 使用数据库找到的值
|
||||||
: defaults.terminalBackgroundOverlayOpacity, // 否则使用默认值
|
: defaults.terminalBackgroundOverlayOpacity, // 否则使用默认值
|
||||||
|
terminal_custom_html: settings.terminal_custom_html ?? defaults.terminal_custom_html,
|
||||||
updatedAt: latestUpdatedAt || defaults.updatedAt, // 使用最新的更新时间,否则使用默认时间戳
|
updatedAt: latestUpdatedAt || defaults.updatedAt, // 使用最新的更新时间,否则使用默认时间戳
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -100,6 +104,7 @@ const getDefaultAppearanceSettings = (): Omit<AppearanceSettings, '_id'> => {
|
|||||||
pageBackgroundImage: undefined,
|
pageBackgroundImage: undefined,
|
||||||
terminalBackgroundEnabled: true, // 默认启用
|
terminalBackgroundEnabled: true, // 默认启用
|
||||||
terminalBackgroundOverlayOpacity: 0.5, // 默认蒙版透明度
|
terminalBackgroundOverlayOpacity: 0.5, // 默认蒙版透明度
|
||||||
|
terminal_custom_html: '', // 默认自定义 HTML 为空字符串
|
||||||
updatedAt: Date.now(), // 提供默认时间戳
|
updatedAt: Date.now(), // 提供默认时间戳
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -127,6 +132,7 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
|
|||||||
{ key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // 数据库中使用空字符串
|
{ key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // 数据库中使用空字符串
|
||||||
{ key: 'terminalBackgroundEnabled', value: defaults.terminalBackgroundEnabled },
|
{ key: 'terminalBackgroundEnabled', value: defaults.terminalBackgroundEnabled },
|
||||||
{ key: 'terminalBackgroundOverlayOpacity', value: defaults.terminalBackgroundOverlayOpacity },
|
{ key: 'terminalBackgroundOverlayOpacity', value: defaults.terminalBackgroundOverlayOpacity },
|
||||||
|
{ key: 'terminal_custom_html', value: defaults.terminal_custom_html },
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -100,7 +100,19 @@ export const updateSettings = async (settingsDto: UpdateAppearanceDto): Promise<
|
|||||||
settingsDto.terminalBackgroundOverlayOpacity = opacity; // 确保类型正确
|
settingsDto.terminalBackgroundOverlayOpacity = opacity; // 确保类型正确
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 如果实现了背景图片上传,这里需要处理文件路径或 URL 的验证/保存逻辑
|
|
||||||
|
|
||||||
|
// 验证 terminal_custom_html (如果提供了)
|
||||||
|
if (settingsDto.hasOwnProperty('terminal_custom_html')) {
|
||||||
|
if (settingsDto.terminal_custom_html === null || settingsDto.terminal_custom_html === undefined || typeof settingsDto.terminal_custom_html === 'string') {
|
||||||
|
// 允许为空字符串、null 或 undefined (将被视为空)
|
||||||
|
if (typeof settingsDto.terminal_custom_html === 'string' && settingsDto.terminal_custom_html.length > 10240) { // 10KB 限制
|
||||||
|
throw new Error('自定义终端 HTML 过长,最多允许 10240 个字符。');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('无效的自定义终端 HTML 类型,应为字符串。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return appearanceRepository.updateAppearanceSettings(settingsDto);
|
return appearanceRepository.updateAppearanceSettings(settingsDto);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface AppearanceSettings {
|
|||||||
editorFontFamily?: string | null; // Monaco Editor 字体偏好
|
editorFontFamily?: string | null; // Monaco Editor 字体偏好
|
||||||
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
|
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
|
||||||
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
|
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
|
||||||
|
terminal_custom_html?: string; // 用户自定义终端背景 HTML
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { TerminalTheme } from '../types/terminal-theme.types';
|
import type { TerminalTheme } from '../types/terminal-theme.types';
|
||||||
import StyleCustomizerUiTab from './style-customizer/StyleCustomizerUiTab.vue';
|
import StyleCustomizerUiTab from './style-customizer/StyleCustomizerUiTab.vue';
|
||||||
@@ -42,15 +42,102 @@ const handleResetUiTheme = async () => {
|
|||||||
|
|
||||||
|
|
||||||
const modalRootRef = ref<HTMLDivElement | null>(null);
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<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 ref="modalRootRef" class="fixed inset-0 z-[1000]" @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">
|
<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 class="flex justify-between items-center px-4 py-3 border-b border-border bg-header flex-shrink-0">
|
<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>
|
<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">×</button>
|
<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">×</button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const terminalOuterWrapperRef = ref<HTMLElement | null>(null); // 最外层容
|
|||||||
let terminal: Terminal | null = null;
|
let terminal: Terminal | null = null;
|
||||||
let fitAddon: FitAddon | null = null;
|
let fitAddon: FitAddon | null = null;
|
||||||
let searchAddon: SearchAddon | null = null; // *** 添加 searchAddon 变量 ***
|
let searchAddon: SearchAddon | null = null; // *** 添加 searchAddon 变量 ***
|
||||||
|
const customHtmlLayerRef = ref<HTMLElement | null>(null); // Ref for the custom HTML layer
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let observedElement: HTMLElement | null = null; // +++ Store the observed element +++
|
let observedElement: HTMLElement | null = null; // +++ Store the observed element +++
|
||||||
let debounceTimer: number | null = null; // 用于防抖的计时器 ID
|
let debounceTimer: number | null = null; // 用于防抖的计时器 ID
|
||||||
@@ -49,6 +50,7 @@ const {
|
|||||||
currentTerminalFontSize,
|
currentTerminalFontSize,
|
||||||
isTerminalBackgroundEnabled,
|
isTerminalBackgroundEnabled,
|
||||||
currentTerminalBackgroundOverlayOpacity, // 获取蒙版透明度
|
currentTerminalBackgroundOverlayOpacity, // 获取蒙版透明度
|
||||||
|
terminalCustomHTML, // 用于自定义终端背景 HTML
|
||||||
} = storeToRefs(appearanceStore);
|
} = storeToRefs(appearanceStore);
|
||||||
|
|
||||||
// --- Settings Store ---
|
// --- Settings Store ---
|
||||||
@@ -614,7 +616,28 @@ defineExpose({ write, findNext, findPrevious, clearSearch, clear }); // 暴露 c
|
|||||||
const applyTerminalBackground = () => {
|
const applyTerminalBackground = () => {
|
||||||
// 背景应用到 terminalOuterWrapperRef
|
// 背景应用到 terminalOuterWrapperRef
|
||||||
if (terminalOuterWrapperRef.value) {
|
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(() => {
|
nextTick(() => {
|
||||||
if (terminalOuterWrapperRef.value) {
|
if (terminalOuterWrapperRef.value) {
|
||||||
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
|
terminalOuterWrapperRef.value.style.backgroundImage = 'none';
|
||||||
@@ -622,45 +645,82 @@ const applyTerminalBackground = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`[Terminal ${props.sessionId}] 终端背景已禁用,移除背景。`);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="terminalOuterWrapperRef" class="terminal-outer-wrapper">
|
<div ref="terminalOuterWrapperRef" class="terminal-outer-wrapper">
|
||||||
<!-- 蒙版层 -->
|
<!-- 蒙版层 -->
|
||||||
<div
|
<div
|
||||||
v-if="isTerminalBackgroundEnabled && terminalBackgroundImage"
|
v-if="isTerminalBackgroundEnabled"
|
||||||
class="terminal-background-overlay"
|
class="terminal-background-overlay"
|
||||||
:style="{ backgroundColor: `rgba(0, 0, 0, ${currentTerminalBackgroundOverlayOpacity})` }"
|
:style="{ backgroundColor: `rgba(0, 0, 0, ${currentTerminalBackgroundOverlayOpacity})` }"
|
||||||
></div>
|
></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 实际挂载点 -->
|
<!-- xterm 实际挂载点 -->
|
||||||
<div ref="terminalRef" class="terminal-inner-container"></div>
|
<div ref="terminalRef" class="terminal-inner-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ const {
|
|||||||
terminalBackgroundImage,
|
terminalBackgroundImage,
|
||||||
isTerminalBackgroundEnabled,
|
isTerminalBackgroundEnabled,
|
||||||
currentTerminalBackgroundOverlayOpacity,
|
currentTerminalBackgroundOverlayOpacity,
|
||||||
|
terminalCustomHTML,
|
||||||
} = storeToRefs(appearanceStore);
|
} = storeToRefs(appearanceStore);
|
||||||
|
|
||||||
const localTerminalBackgroundEnabled = ref(true);
|
const localTerminalBackgroundEnabled = ref(true);
|
||||||
const editableTerminalBackgroundOverlayOpacity = ref(0.5);
|
const editableTerminalBackgroundOverlayOpacity = ref(0.5);
|
||||||
|
const localTerminalCustomHTML = ref('');
|
||||||
|
|
||||||
const terminalBgFileInput = ref<HTMLInputElement | null>(null);
|
const terminalBgFileInput = ref<HTMLInputElement | null>(null);
|
||||||
const uploadError = ref<string | null>(null);
|
const uploadError = ref<string | null>(null);
|
||||||
@@ -23,6 +25,7 @@ const uploadError = ref<string | null>(null);
|
|||||||
const initializeEditableState = () => {
|
const initializeEditableState = () => {
|
||||||
localTerminalBackgroundEnabled.value = isTerminalBackgroundEnabled.value;
|
localTerminalBackgroundEnabled.value = isTerminalBackgroundEnabled.value;
|
||||||
editableTerminalBackgroundOverlayOpacity.value = currentTerminalBackgroundOverlayOpacity.value;
|
editableTerminalBackgroundOverlayOpacity.value = currentTerminalBackgroundOverlayOpacity.value;
|
||||||
|
localTerminalCustomHTML.value = terminalCustomHTML.value || '';
|
||||||
uploadError.value = null;
|
uploadError.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,6 +43,13 @@ watch(currentTerminalBackgroundOverlayOpacity, (newValue) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
watch(terminalCustomHTML, (newValue) => {
|
||||||
|
if (localTerminalCustomHTML.value !== (newValue || '')) {
|
||||||
|
localTerminalCustomHTML.value = newValue || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleTriggerTerminalBgUpload = () => {
|
const handleTriggerTerminalBgUpload = () => {
|
||||||
uploadError.value = null;
|
uploadError.value = null;
|
||||||
terminalBgFileInput.value?.click();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<!-- 自定义终端背景 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>
|
||||||
<div v-else class="p-4 text-center text-text-secondary italic border border-dashed border-border/50 rounded-md">
|
<div v-else class="p-4 text-center text-text-secondary italic border border-dashed border-border/50 rounded-md">
|
||||||
{{ t('styleCustomizer.terminalBgDisabled', '终端背景功能已禁用。') }}
|
{{ t('styleCustomizer.terminalBgDisabled', '终端背景功能已禁用。') }}
|
||||||
|
|||||||
@@ -57,10 +57,6 @@ brightMagenta: #ff55ff
|
|||||||
brightCyan: #55ffff
|
brightCyan: #55ffff
|
||||||
brightWhite: #ffffff`;
|
brightWhite: #ffffff`;
|
||||||
// Theme preview refs
|
// 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 = () => {
|
const initializeEditableState = () => {
|
||||||
editableTerminalFontFamily.value = currentTerminalFontFamily.value;
|
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
|
// Computed Properties
|
||||||
const activeThemeName = computed(() => {
|
const activeThemeName = computed(() => {
|
||||||
@@ -543,15 +472,6 @@ watch(() => props.isEditingTheme, (isEditing) => {
|
|||||||
>
|
>
|
||||||
{{ t('styleCustomizer.applyButton', 'Apply') }}
|
{{ t('styleCustomizer.applyButton', 'Apply') }}
|
||||||
</button>
|
</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')"
|
<button @click="handleEditTheme(theme)" :title="theme.isPreset ? t('styleCustomizer.editAsCopy', 'Edit as Copy') : t('common.edit')"
|
||||||
:class="[
|
: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',
|
'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
|
return typeof opacity === 'number' && opacity >= 0 && opacity <= 1 ? opacity : 0.5; // 默认 0.5
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 获取终端自定义 CSS
|
||||||
|
const terminalCustomHTML = computed(() => appearanceSettings.value.terminal_custom_html ?? null);
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,6 +310,22 @@ export const useAppearanceStore = defineStore('appearance', () => {
|
|||||||
await updateAppearanceSettings({ terminalBackgroundOverlayOpacity: opacity });
|
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 ---
|
// --- 终端主题列表管理 Actions ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -676,10 +695,12 @@ export const useAppearanceStore = defineStore('appearance', () => {
|
|||||||
uploadPageBackground,
|
uploadPageBackground,
|
||||||
uploadTerminalBackground,
|
uploadTerminalBackground,
|
||||||
setTerminalBackgroundOverlayOpacity,
|
setTerminalBackgroundOverlayOpacity,
|
||||||
|
setTerminalCustomHTML, // 设置终端自定义 HTML
|
||||||
removePageBackground,
|
removePageBackground,
|
||||||
removeTerminalBackground,
|
removeTerminalBackground,
|
||||||
loadTerminalThemeData,
|
loadTerminalThemeData,
|
||||||
isTerminalBackgroundEnabled,
|
isTerminalBackgroundEnabled,
|
||||||
|
terminalCustomHTML, // 获取终端自定义 HTML
|
||||||
startTerminalThemePreview,
|
startTerminalThemePreview,
|
||||||
stopTerminalThemePreview,
|
stopTerminalThemePreview,
|
||||||
// Visibility control
|
// Visibility control
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface AppearanceSettings {
|
|||||||
editorFontFamily?: string | null; // Monaco Editor 字体偏好
|
editorFontFamily?: string | null; // Monaco Editor 字体偏好
|
||||||
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
|
terminalBackgroundEnabled?: boolean; // 终端背景是否启用
|
||||||
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
|
terminalBackgroundOverlayOpacity?: number; // 终端背景蒙版透明度 (0-1)
|
||||||
|
terminal_custom_html?: string | null; // 终端自定义 HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前端用于更新外观设置的数据结构 (对应 API 请求体)
|
// 前端用于更新外观设置的数据结构 (对应 API 请求体)
|
||||||
|
|||||||
Reference in New Issue
Block a user