Files
nexus-terminal/packages/frontend/src/components/StyleCustomizer.vue
T
Baobhan Sith 2ca4b957e6 update
2025-04-19 10:38:37 +08:00

1334 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAppearanceStore } from '../stores/appearance.store'; // 使用新的 store
import { storeToRefs } from 'pinia';
import type { ITheme } from 'xterm';
import type { TerminalTheme } from '../../../backend/src/types/terminal-theme.types'; // 引入类型
import { defaultXtermTheme } from '../stores/default-themes'; // 引入默认主题
const { t } = useI18n();
const appearanceStore = useAppearanceStore();
const {
appearanceSettings, // <-- 添加这个 ref
currentUiTheme,
// currentTerminalTheme, // 这个是计算属性,只读,在编辑时不需要直接用
activeTerminalThemeId,
availableTerminalThemes,
currentTerminalFontFamily,
currentTerminalFontSize,
currentEditorFontSize, // <-- 新增
pageBackgroundImage,
// pageBackgroundOpacity, // Removed
terminalBackgroundImage,
// terminalBackgroundOpacity, // Removed
} = storeToRefs(appearanceStore);
// --- 本地状态用于编辑 ---
const editableUiTheme = ref<Record<string, string>>({});
const editableTerminalFontFamily = ref('');
const editableTerminalFontSize = ref(14);
const editableEditorFontSize = ref(14); // <-- 新增,编辑器字体大小
// const editablePageBackgroundOpacity = ref(1.0); // Removed
// const editableTerminalBackgroundOpacity = ref(1.0); // Removed
const editableUiThemeString = ref(''); // 用于 textarea 绑定
const themeParseError = ref<string | null>(null); // 用于显示 JSON 解析错误
// 终端主题管理相关状态
const selectedTerminalThemeId = ref<string | null>(null); // 下拉框选择的 ID
const isEditingTheme = ref(false); // 是否正在编辑某个主题
// 使用 reactive 确保嵌套对象 themeData 的响应性
// 修正:editingTheme 应该是一个 ref 包含 TerminalTheme 或 null
const editingTheme = ref<TerminalTheme | null>(null); // 正在编辑的主题数据副本 (完整结构)
const newThemeName = ref(''); // 新建主题的名称 (不再需要,直接编辑 editingTheme.value.name)
// 文件上传相关
const pageBgFileInput = ref<HTMLInputElement | null>(null);
const terminalBgFileInput = ref<HTMLInputElement | null>(null);
const themeImportInput = ref<HTMLInputElement | null>(null);
const uploadError = ref<string | null>(null);
const importError = ref<string | null>(null);
const saveThemeError = ref<string | null>(null); // 用于显示保存主题时的错误
// 初始化本地编辑状态
import { defaultUiTheme } from '../stores/default-themes'; // 确保导入默认主题
import { safeJsonParse } from '../stores/appearance.store'; // 导入辅助函数
const initializeEditableState = () => {
// 获取用户保存的主题或空对象
// 注意:直接从 store 的 appearanceSettings 获取原始字符串,避免依赖 currentUiTheme 计算属性可能带来的延迟或缓存问题
const userThemeJson = appearanceSettings.value.customUiTheme;
const userTheme = safeJsonParse(userThemeJson, {});
// 合并默认主题和用户主题,确保所有默认键存在,并优先使用用户值
const mergedTheme = { ...defaultUiTheme, ...userTheme }; // 用户值覆盖默认值
// 深拷贝合并后的主题到 editableUiTheme
editableUiTheme.value = JSON.parse(JSON.stringify(mergedTheme));
// --- 其他初始化保持不变 ---
editableTerminalFontFamily.value = currentTerminalFontFamily.value;
editableTerminalFontSize.value = currentTerminalFontSize.value;
editableEditorFontSize.value = currentEditorFontSize.value; // <-- 新增
selectedTerminalThemeId.value = activeTerminalThemeId.value ?? null; // 初始化下拉框
uploadError.value = null;
importError.value = null;
saveThemeError.value = null;
themeParseError.value = null; // 初始化解析错误
// 初始化 textarea 内容 (基于合并后的主题)
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 = ''; // Fallback to empty
}
};
onMounted(initializeEditableState);
// 监听 store 变化以更新本地状态 (例如重置或外部更改)
// 只监听不需要编辑的状态或用于初始化的状态
// 监听 store 中可能影响初始化状态的值
// 主要监听 appearanceSettings (包含 customUiTheme) 和 activeTerminalThemeId
// 不再直接监听 currentUiTheme 计算属性,因为 initializeEditableState 现在直接处理合并逻辑
watch([
() => appearanceStore.appearanceSettings, // 监听整个设置对象的变化
activeTerminalThemeId
], (newVals, oldVals) => {
// newVals[0] 是新的 appearanceSettings 对象
// newVals[1] 是新的 activeTerminalThemeId
const newSettings = newVals[0];
const oldSettings = oldVals ? oldVals[0] : {}; // 可能没有旧值
const newActiveThemeId = newVals[1];
const oldActiveThemeId = oldVals ? oldVals[1] : null;
// 仅当非编辑状态时,或活动终端主题ID变化时,或 UI 主题设置本身发生变化时 (例如重置),才重新初始化
if (!isEditingTheme.value || newActiveThemeId !== oldActiveThemeId || newSettings?.customUiTheme !== oldSettings?.customUiTheme) {
console.log('[StyleCustomizer] Watch triggered re-initialization.');
initializeEditableState(); // 调用修改后的初始化函数
} else {
// 如果正在编辑,只更新非编辑相关的部分 (不包括 UI 主题,因为它由 initializeEditableState 处理)
console.log('[StyleCustomizer] Watch triggered partial update (editing).');
// editableUiTheme.value = JSON.parse(JSON.stringify(newVals[0] || {})); // 移除或注释掉,避免覆盖编辑状态
// 确保从正确的 newVals 索引获取值,现在 watch 的依赖项变了
// 假设 appearanceSettings 是第一个依赖,activeTerminalThemeId 是第二个
// 字体等信息需要从 newSettings 中获取
editableTerminalFontFamily.value = newSettings?.terminalFontFamily || '';
editableTerminalFontSize.value = newSettings?.terminalFontSize || 14;
editableEditorFontSize.value = newSettings?.editorFontSize || 14; // <-- 新增同步
}
}, { deep: true });
const emit = defineEmits(['close']);
const closeCustomizer = () => {
// 如果正在编辑主题,提示用户是否放弃更改
if (isEditingTheme.value) {
if (confirm(t('styleCustomizer.confirmCloseEditing'))) {
isEditingTheme.value = false; // 退出编辑状态
editingTheme.value = null;
emit('close');
}
} else {
emit('close');
}
};
// 当前活动的标签页
const currentTab = ref<'ui' | 'terminal' | 'background' | 'other'>('ui'); // <-- 添加 'other'
// --- 处理函数 ---
// 保存 UI 主题更改
const handleSaveUiTheme = async () => {
try {
await appearanceStore.saveCustomUiTheme(editableUiTheme.value);
alert(t('styleCustomizer.uiThemeSaved')); // 简单提示
} catch (error: any) {
console.error("保存 UI 主题失败:", error);
alert(t('styleCustomizer.uiThemeSaveFailed', { message: error.message }));
}
};
// 重置 UI 主题
const handleResetUiTheme = async () => {
if (confirm(t('styleCustomizer.confirmResetUi'))) {
try {
await appearanceStore.resetCustomUiTheme();
// watch 会自动更新 editableUiTheme.value
alert(t('styleCustomizer.uiThemeReset'));
} catch (error: any) {
console.error("重置 UI 主题失败:", error);
alert(t('styleCustomizer.uiThemeResetFailed', { message: error.message }));
}
}
};
// --- Textarea 和 Color Picker 同步 ---
// 计算属性:将本地编辑的 UI 主题对象格式化为内部键值对字符串(无大括号,无行尾逗号)
const formattedEditableUiThemeJson = computed(() => {
try {
const themeObject = editableUiTheme.value;
if (!themeObject || typeof themeObject !== 'object' || Object.keys(themeObject).length === 0) {
return ''; // Return empty string if no theme or empty
}
// Generate key-value pairs, indented, one per line
// Generate key-value pairs, indented, one per line, without quotes for easier editing
const lines = Object.entries(themeObject).map(([key, value]) => {
// Output key and value directly without leading spaces
return `${key}: ${value}`;
});
// Join with newline
return lines.join('\n');
} catch (e) {
console.error("序列化可编辑 UI 主题键值对失败:", e);
return ''; // 回退为空字符串
}
});
// 监听计算属性的变化(通常由颜色选择器引起),更新 textarea 的内容
watch(formattedEditableUiThemeJson, (newJson) => {
// 只有在 textarea 没有聚焦时才更新,避免覆盖用户输入
// 或者,如果解析错误存在,也允许更新以显示正确格式
if (document.activeElement?.id !== 'uiThemeTextarea' || themeParseError.value) {
editableUiThemeString.value = newJson;
if (themeParseError.value && document.activeElement?.id !== 'uiThemeTextarea') {
themeParseError.value = null; // 如果外部更改修复了错误,清除错误提示
}
}
});
// 处理 textarea 内容变化(失去焦点时)
// 处理 textarea 内容变化(失去焦点时)
// 处理 textarea 内容变化(失去焦点时)
// 处理 textarea 内容变化(失去焦点时)
// 处理 textarea 内容变化(失去焦点时)
// 处理 textarea 内容变化(失去焦点时)
// 处理 textarea 内容变化(失去焦点时)
const handleUiThemeStringChange = () => {
themeParseError.value = null; // 清除之前的错误
let inputText = editableUiThemeString.value.trim();
// 如果内容为空,则视为空对象
if (!inputText) {
editableUiTheme.value = {};
return;
}
// 准备构建 JSON 字符串
let jsonStringToParse = inputText
.split('\n') // 按行分割
.map(line => line.trim()) // 去除每行首尾空格
.filter(line => line && line.includes(':')) // 过滤空行和不包含冒号的行
.map(line => {
// 尝试为 key 和 value 添加引号(如果缺少)
const parts = line.split(/:(.*)/s); //
if (parts.length < 2) return null; // 无效行
let key = parts[0].trim();
let value = parts[1].trim();
// 为 key 添加引号(如果需要)
// 移除 key 可能存在的引号再用 stringify 包裹
if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
}
if (key.startsWith("'") && key.endsWith("'")) {
key = key.slice(1, -1);
}
key = JSON.stringify(key); // 使用 JSON.stringify 保证正确转义
// 为 value 添加引号(如果需要,且不是数字/布尔值/null)
// 移除可能的尾随逗号
if (value.endsWith(',')) {
value = value.slice(0, -1).trim();
}
// 移除 value 可能存在的引号再判断
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); // 使用原始未加引号的值进行 stringify
} else {
// 对于数字、布尔值、null,不需要加引号
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 ref,这将触发颜色选择器的更新
editableUiTheme.value = parsedTheme;
// 注意:此时尚未保存到后端,用户需要点击“保存 UI 主题”按钮
} 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 handleSaveTerminalFont = async () => {
try {
await appearanceStore.setTerminalFontFamily(editableTerminalFontFamily.value);
alert(t('styleCustomizer.terminalFontSaved'));
} catch (error: any) {
console.error("保存终端字体失败:", error);
alert(t('styleCustomizer.terminalFontSaveFailed', { message: error.message }));
}
};
// 保存终端字体大小
const handleSaveTerminalFontSize = async () => {
try {
const size = Number(editableTerminalFontSize.value);
if (isNaN(size) || size <= 0) {
alert(t('styleCustomizer.errorInvalidFontSize')); // 需要添加翻译
return;
}
await appearanceStore.setTerminalFontSize(size);
alert(t('styleCustomizer.terminalFontSizeSaved')); // 需要添加翻译
} catch (error: any) {
console.error("保存终端字体大小失败:", error);
alert(t('styleCustomizer.terminalFontSizeSaveFailed', { message: error.message })); // 需要添加翻译
}
};
// 保存编辑器字体大小
const handleSaveEditorFontSize = async () => {
try {
const size = Number(editableEditorFontSize.value);
if (isNaN(size) || size <= 0) {
alert(t('styleCustomizer.errorInvalidEditorFontSize')); // 需要添加翻译
return;
}
await appearanceStore.setEditorFontSize(size);
alert(t('styleCustomizer.editorFontSizeSaved')); // 需要添加翻译
} catch (error: any) {
console.error("保存编辑器字体大小失败:", error);
alert(t('styleCustomizer.editorFontSizeSaveFailed', { message: error.message })); // 需要添加翻译
}
};
// 更改激活的终端主题
const handleTerminalThemeChange = async () => {
try {
await appearanceStore.setActiveTerminalTheme(selectedTerminalThemeId.value);
} catch (error: any) {
console.error("设置激活终端主题失败:", error);
// 恢复下拉框选择到之前的状态
selectedTerminalThemeId.value = activeTerminalThemeId.value ?? null;
alert(t('styleCustomizer.setActiveThemeFailed', { message: error.message }));
}
};
// --- 终端主题管理 ---
// 开始新建主题
const handleAddNewTheme = () => {
saveThemeError.value = null; // 清除旧错误
// 创建一个全新的默认主题结构用于编辑
editingTheme.value = {
_id: undefined, // 清除 ID 表示是新建
name: t('styleCustomizer.newThemeDefaultName'),
themeData: JSON.parse(JSON.stringify(defaultXtermTheme)), // 使用默认 xterm 主题作为基础
isPreset: false, // 明确不是预设
};
isEditingTheme.value = true;
};
// 开始编辑现有主题
const handleEditTheme = (theme: TerminalTheme) => {
if (theme.isPreset) return; // 不允许编辑预设
saveThemeError.value = null; // 清除旧错误
// 深拷贝以避免直接修改列表中的对象
editingTheme.value = JSON.parse(JSON.stringify(theme));
isEditingTheme.value = true;
};
// 保存主题编辑 (新建或更新)
const handleSaveEditingTheme = async () => {
if (!editingTheme.value || !editingTheme.value.name) {
saveThemeError.value = t('styleCustomizer.errorThemeNameRequired');
return;
}
saveThemeError.value = null; // 清除错误
try {
if (editingTheme.value._id) { // 更新
// 确保传递的是 UpdateTerminalThemeDto 兼容的格式
const updateDto = { name: editingTheme.value.name, themeData: editingTheme.value.themeData };
await appearanceStore.updateTerminalTheme(
editingTheme.value._id,
updateDto.name,
updateDto.themeData
);
alert(t('styleCustomizer.themeUpdatedSuccess'));
} else { // 新建
// 确保传递的是 CreateTerminalThemeDto 兼容的格式
const createDto = { name: editingTheme.value.name, themeData: editingTheme.value.themeData };
await appearanceStore.createTerminalTheme(
createDto.name,
createDto.themeData
);
alert(t('styleCustomizer.themeCreatedSuccess'));
}
isEditingTheme.value = false; // 关闭编辑
editingTheme.value = null;
} catch (error: any) {
console.error("保存终端主题失败:", error);
saveThemeError.value = error.message || t('styleCustomizer.themeSaveFailed');
}
};
// 取消编辑
const handleCancelEditingTheme = () => {
isEditingTheme.value = false;
editingTheme.value = null;
saveThemeError.value = null;
};
// 删除主题
const handleDeleteTheme = async (theme: TerminalTheme) => {
if (theme.isPreset) return;
if (confirm(t('styleCustomizer.confirmDeleteTheme', { name: theme.name }))) {
try {
await appearanceStore.deleteTerminalTheme(theme._id!);
alert(t('styleCustomizer.themeDeletedSuccess'));
} catch (error: any) {
console.error("删除终端主题失败:", error);
alert(t('styleCustomizer.themeDeleteFailed', { message: error.message }));
}
}
};
// 触发主题导入文件选择
const handleTriggerImport = () => {
importError.value = null;
themeImportInput.value?.click();
};
// 处理主题导入
const handleImportThemeFile = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
const file = input.files[0];
try {
// 可以选择在前端解析文件名作为默认名称传递给后端
const defaultName = file.name.endsWith('.json') ? file.name.slice(0, -5) : file.name;
await appearanceStore.importTerminalTheme(file, defaultName); // 传递文件名作为备选名称
alert(t('styleCustomizer.importSuccess'));
input.value = ''; // 清空文件输入,以便再次选择相同文件
} catch (error: any) {
console.error("导入主题失败:", error);
importError.value = error.message || t('styleCustomizer.importFailed');
input.value = '';
}
}
};
// 处理主题导出
const handleExportTheme = async () => {
if (selectedTerminalThemeId.value) {
try {
await appearanceStore.exportTerminalTheme(selectedTerminalThemeId.value);
} catch (error: any) {
console.error("导出主题失败:", error);
alert(t('styleCustomizer.exportFailed', { message: error.message }));
}
}
};
// --- 背景处理 ---
const handleTriggerPageBgUpload = () => {
uploadError.value = null;
pageBgFileInput.value?.click();
};
const handleTriggerTerminalBgUpload = () => {
uploadError.value = null;
terminalBgFileInput.value?.click();
};
const handlePageBgUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files[0]) {
const file = input.files[0];
try {
await appearanceStore.uploadPageBackground(file);
alert(t('styleCustomizer.pageBgUploadSuccess'));
input.value = ''; // 清空以便再次选择
} catch (error: any) {
uploadError.value = error.message || t('styleCustomizer.uploadFailed');
input.value = '';
}
}
};
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);
alert(t('styleCustomizer.terminalBgUploadSuccess'));
input.value = '';
} catch (error: any) {
uploadError.value = error.message || t('styleCustomizer.uploadFailed');
input.value = '';
}
}
};
const handleRemovePageBg = async () => {
if (confirm(t('styleCustomizer.confirmRemovePageBg'))) {
try {
await appearanceStore.removePageBackground();
alert(t('styleCustomizer.pageBgRemoved'));
} catch (error: any) {
console.error("移除页面背景失败:", error);
alert(t('styleCustomizer.removeBgFailed', { message: error.message }));
}
}
};
const handleRemoveTerminalBg = async () => {
if (confirm(t('styleCustomizer.confirmRemoveTerminalBg'))) {
try {
await appearanceStore.removeTerminalBackground();
alert(t('styleCustomizer.terminalBgRemoved'));
} catch (error: any) {
console.error("移除终端背景失败:", error);
alert(t('styleCustomizer.removeBgFailed', { message: error.message }));
}
}
};
// Removed handlePageOpacityChange and handleTerminalOpacityChange functions
// --- 辅助函数 ---
// 格式化 UI 主题标签
const formatLabel = (key: string): string => {
// 简单的转换逻辑,可以根据需要优化
return key
.replace(/^--/, '') // 移除前缀 '--'
.replace(/-/g, ' ') // 替换 '-' 为空格
.replace(/([A-Z])/g, ' $1') // 在大写字母前加空格
.replace(/^./, (str) => str.toUpperCase()); // 首字母大写
};
// 格式化 xterm 主题属性标签
const formatXtermLabel = (key: keyof ITheme): string => {
// 简单的转换逻辑
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase());
};
</script>
<template>
<div class="style-customizer-overlay" @click.self="closeCustomizer">
<div class="style-customizer-panel">
<header class="panel-header">
<h2>{{ t('styleCustomizer.title') }}</h2>
<button @click="closeCustomizer" class="close-button">&times;</button>
</header>
<div class="panel-content">
<nav class="panel-nav">
<button @click="currentTab = 'ui'" :class="{ active: currentTab === 'ui' }">
{{ t('styleCustomizer.uiStyles') }}
</button>
<button @click="currentTab = 'terminal'" :class="{ active: currentTab === 'terminal' && !isEditingTheme }" :disabled="isEditingTheme">
{{ t('styleCustomizer.terminalStyles') }}
</button>
<button @click="currentTab = 'background'" :class="{ active: currentTab === 'background' }" :disabled="isEditingTheme">
{{ t('styleCustomizer.backgroundSettings') }}
</button>
<button @click="currentTab = 'other'" :class="{ active: currentTab === 'other' }" :disabled="isEditingTheme">
{{ t('styleCustomizer.otherSettings') }} <!-- 需要添加翻译 -->
</button>
</nav>
<main class="panel-main">
<section v-if="currentTab === 'ui'">
<h3>{{ t('styleCustomizer.uiStyles') }}</h3>
<p>{{ t('styleCustomizer.uiDescription') }}</p>
<!-- 动态生成 UI 样式编辑控件 -->
<div v-for="(value, key) in editableUiTheme" :key="key" class="form-group">
<label :for="`ui-${key}`">{{ formatLabel(key) }}:</label>
<!-- 简单判断是否为颜色值显示颜色选择器 -->
<input
v-if="typeof value === 'string' && (value.startsWith('#') || value.startsWith('rgb') || value.startsWith('hsl'))"
type="color"
:id="`ui-${key}`"
v-model="editableUiTheme[key]"
/>
<!-- 否则显示文本输入框 -->
<input
v-else
type="text"
:id="`ui-${key}`"
v-model="editableUiTheme[key]"
class="text-input"
/>
</div>
<!-- UI Theme Textarea -->
<hr style="margin-top: calc(var(--base-padding) * 2); margin-bottom: calc(var(--base-padding) * 2);">
<h4>{{ t('styleCustomizer.uiThemeJsonEditorTitle') }}</h4> <!-- TODO: Add translation -->
<p>{{ t('styleCustomizer.uiThemeJsonEditorDesc') }}</p> <!-- TODO: Add translation -->
<div class="form-group full-width-group"> <!-- Use a class for full width -->
<label for="uiThemeTextarea" class="sr-only">{{ t('styleCustomizer.uiThemeJsonEditorTitle') }}</label> <!-- Screen reader only label -->
<textarea
id="uiThemeTextarea"
v-model="editableUiThemeString"
@blur="handleUiThemeStringChange"
rows="15"
:placeholder="'--app-bg-color: #ffffff\n--text-color: #333333\n...'"
spellcheck="false"
class="json-textarea"
></textarea>
</div>
<p v-if="themeParseError" class="error-message full-width-group">{{ themeParseError }}</p>
</section>
<section v-if="currentTab === 'terminal' && !isEditingTheme">
<h3>{{ t('styleCustomizer.terminalStyles') }}</h3>
<!-- 终端字体设置 -->
<div class="form-group">
<label for="terminalFontFamily">{{ t('styleCustomizer.terminalFontFamily') }}:</label>
<input type="text" id="terminalFontFamily" v-model="editableTerminalFontFamily" class="text-input wide-input" :placeholder="t('styleCustomizer.terminalFontPlaceholder')"/>
<button @click="handleSaveTerminalFont" class="button-inline">{{ t('common.save') }}</button>
</div>
<p class="setting-description">{{ t('styleCustomizer.terminalFontDescription') }}</p>
<!-- 终端字体大小设置 -->
<div class="form-group">
<label for="terminalFontSize">{{ t('styleCustomizer.terminalFontSize') }}:</label> <!-- 需要添加翻译 -->
<input type="number" id="terminalFontSize" v-model.number="editableTerminalFontSize" class="number-input" min="1" />
<button @click="handleSaveTerminalFontSize" class="button-inline">{{ t('common.save') }}</button>
</div>
<hr>
<!-- 终端主题选择与管理 -->
<h4>{{ t('styleCustomizer.terminalThemeSelection') }}</h4>
<div class="form-group">
<label for="terminalThemeSelect">{{ t('styleCustomizer.activeTheme') }}:</label>
<select id="terminalThemeSelect" v-model="selectedTerminalThemeId" @change="handleTerminalThemeChange">
<option :value="null">{{ t('styleCustomizer.selectThemePrompt') }}</option> <!-- 添加一个空选项或默认选项 -->
<option v-for="theme in availableTerminalThemes" :key="theme._id" :value="theme._id">
{{ theme.name }} {{ theme.isPreset ? `(${t('styleCustomizer.preset')})` : '' }}
</option>
</select>
<button @click="handleExportTheme" :disabled="!selectedTerminalThemeId" class="button-inline">{{ t('styleCustomizer.exportTheme') }}</button>
</div>
<div class="theme-management-buttons">
<button @click="handleAddNewTheme">{{ t('styleCustomizer.addNewTheme') }}</button>
<button @click="handleTriggerImport">{{ t('styleCustomizer.importTheme') }}</button>
<input type="file" ref="themeImportInput" @change="handleImportThemeFile" accept=".json" style="display: none;" />
<p v-if="importError" class="error-message">{{ importError }}</p>
</div>
<!-- 主题列表 -->
<ul class="theme-list">
<li v-for="theme in availableTerminalThemes" :key="theme._id" :class="{ 'preset-theme': theme.isPreset }">
<span>{{ theme.name }} {{ theme.isPreset ? `(${t('styleCustomizer.preset')})` : '' }}</span>
<div class="theme-actions">
<button @click="handleEditTheme(theme)" :disabled="theme.isPreset">{{ t('common.edit') }}</button>
<button @click="handleDeleteTheme(theme)" :disabled="theme.isPreset" class="button-danger">{{ t('common.delete') }}</button>
</div>
</li>
</ul>
</section>
<!-- 主题编辑器 -->
<section v-if="isEditingTheme && editingTheme">
<h3>{{ editingTheme._id ? t('styleCustomizer.editThemeTitle') : t('styleCustomizer.newThemeTitle') }}</h3>
<p v-if="saveThemeError" class="error-message">{{ saveThemeError }}</p>
<div class="form-group">
<label for="editingThemeName">{{ t('styleCustomizer.themeName') }}:</label>
<input type="text" id="editingThemeName" v-model="editingTheme.name" required class="text-input"/>
</div>
<!-- 动态生成终端样式编辑控件 -->
<div v-for="(value, key) in editingTheme.themeData" :key="key" class="form-group">
<label :for="`xterm-${key}`">{{ formatXtermLabel(key as keyof ITheme) }}:</label>
<!-- 简单判断是否为颜色值 -->
<input
v-if="typeof value === 'string' && value.startsWith('#')"
type="color"
:id="`xterm-${key}`"
v-model="(editingTheme.themeData as any)[key]"
/>
<!-- 其他类型如数字布尔值可以添加相应控件这里简化为文本 -->
<input
v-else
type="text"
:id="`xterm-${key}`"
v-model="(editingTheme.themeData as any)[key]"
class="text-input"
/>
</div>
<div class="editor-footer">
<button @click="handleCancelEditingTheme" class="button-secondary">{{ t('common.cancel') }}</button>
<button @click="handleSaveEditingTheme" class="button-primary">{{ t('common.save') }}</button>
</div>
</section>
<section v-if="currentTab === 'background'">
<h3>{{ t('styleCustomizer.backgroundSettings') }}</h3>
<!-- 页面背景 -->
<h4>{{ t('styleCustomizer.pageBackground') }}</h4>
<div class="background-preview" :style="{ backgroundImage: pageBackgroundImage ? `url(${pageBackgroundImage})` : 'none' }">
{{ pageBackgroundImage ? '' : t('styleCustomizer.noBackground') }}
</div>
<div class="background-controls">
<button @click="handleTriggerPageBgUpload">{{ t('styleCustomizer.uploadPageBg') }}</button>
<button @click="handleRemovePageBg" :disabled="!pageBackgroundImage" class="button-danger">{{ t('styleCustomizer.removePageBg') }}</button>
<input type="file" ref="pageBgFileInput" @change="handlePageBgUpload" accept="image/*" style="display: none;" />
</div>
<!-- Removed Opacity Slider -->
<p v-if="uploadError" class="error-message">{{ uploadError }}</p>
<hr>
<!-- 终端背景 -->
<h4>{{ t('styleCustomizer.terminalBackground') }}</h4>
<div class="background-preview" :style="{ backgroundImage: terminalBackgroundImage ? `url(${terminalBackgroundImage})` : 'none' }">
{{ terminalBackgroundImage ? '' : t('styleCustomizer.noBackground') }}
</div>
<div class="background-controls">
<button @click="handleTriggerTerminalBgUpload">{{ t('styleCustomizer.uploadTerminalBg') }}</button>
<button @click="handleRemoveTerminalBg" :disabled="!terminalBackgroundImage" class="button-danger">{{ t('styleCustomizer.removeTerminalBg') }}</button>
<input type="file" ref="terminalBgFileInput" @change="handleTerminalBgUpload" accept="image/*" style="display: none;" />
</div>
<!-- Removed Opacity Slider -->
</section>
<section v-if="currentTab === 'other'">
<h3>{{ t('styleCustomizer.otherSettings') }}</h3> <!-- 需要添加翻译 -->
<!-- 编辑器字体大小设置 -->
<div class="form-group">
<label for="editorFontSize">{{ t('styleCustomizer.editorFontSize') }}:</label> <!-- 需要添加翻译 -->
<input type="number" id="editorFontSize" v-model.number="editableEditorFontSize" class="number-input" min="1" />
<button @click="handleSaveEditorFontSize" class="button-inline">{{ t('common.save') }}</button>
</div>
</section>
</main>
</div>
<footer class="panel-footer">
<!-- 根据当前 tab 或状态显示不同的按钮 -->
<button v-if="currentTab === 'ui'" @click="handleResetUiTheme" class="button-secondary">{{ t('styleCustomizer.resetUiTheme') }}</button>
<button v-if="currentTab === 'ui'" @click="handleSaveUiTheme" class="button-primary">{{ t('styleCustomizer.saveUiTheme') }}</button>
<!-- 终端字体和主题选择是即时保存的不需要单独的保存按钮 -->
<!-- 背景设置也是即时保存的 -->
<button @click="closeCustomizer" class="button-secondary">{{ t('common.close') }}</button> <!-- 添加一个通用的关闭按钮 -->
</footer>
</div>
</div>
</template>
<style scoped>
.style-customizer-overlay {
position: fixed;
inset: 0; /* top, right, bottom, left = 0 */
background-color: rgba(0, 0, 0, 0.6); /* 更深的遮罩 */
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.style-customizer-panel {
background-color: var(--app-bg-color);
color: var(--text-color);
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
width: 90%;
max-width: 800px; /* 增加最大宽度 */
height: 85vh; /* 使用视口高度 */
max-height: 700px; /* 限制最大高度 */
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: calc(var(--base-padding) * 0.8) var(--base-padding); /* 调整内边距 */
border-bottom: 1px solid var(--border-color);
background-color: var(--header-bg-color);
flex-shrink: 0; /* 防止头部被压缩 */
}
.panel-header h2 {
margin: 0;
font-size: 1.25rem; /* 稍大标题 */
color: var(--text-color);
}
.close-button {
background: none;
border: none;
font-size: 1.8rem; /* 增大关闭按钮 */
line-height: 1;
cursor: pointer;
color: var(--text-color-secondary);
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.close-button:hover {
color: var(--text-color);
background-color: rgba(0,0,0,0.1);
}
.panel-content {
display: flex;
flex-grow: 1;
overflow: hidden; /* 防止内部滚动条影响布局 */
}
.panel-nav {
width: 180px; /* 增加导航宽度 */
border-right: 1px solid var(--border-color);
padding: var(--base-padding);
background-color: var(--header-bg-color);
flex-shrink: 0;
overflow-y: auto; /* 导航内部滚动 */
}
.panel-nav button {
display: block;
width: 100%;
padding: 0.7rem 0.8rem; /* 增加导航按钮内边距 */
margin-bottom: var(--base-margin);
text-align: left;
background: none;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
color: var(--text-color);
font-size: 0.95rem;
transition: background-color 0.2s ease, color 0.2s ease;
}
.panel-nav button:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.panel-nav button.active {
background-color: var(--button-bg-color); /* 使用按钮背景色 */
color: var(--button-text-color);
font-weight: bold;
}
.panel-nav button:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: transparent !important;
color: var(--text-color-secondary);
}
.panel-main {
flex-grow: 1;
padding: var(--base-padding) calc(var(--base-padding) * 1.5); /* 增加左右内边距 */
overflow-y: auto; /* 主要内容区域滚动 */
}
.panel-main h3 {
margin-top: 0;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--base-margin);
margin-bottom: calc(var(--base-margin) * 2);
font-size: 1.15rem;
color: var(--text-color);
}
.panel-main h4 {
margin-top: calc(var(--base-margin) * 2.5);
margin-bottom: var(--base-margin);
color: var(--text-color);
font-size: 1.05rem;
}
.panel-main p:not(.error-message):not(.setting-description) {
color: var(--text-color-secondary);
font-size: 0.9rem;
line-height: 1.6;
margin-bottom: calc(var(--base-margin) * 1.5);
}
.setting-description {
font-size: 0.85rem;
color: var(--text-color-secondary);
margin-top: calc(var(--base-margin) / -2); /* 减少与上方元素的间距 */
margin-bottom: var(--base-margin);
}
.form-group {
margin-bottom: calc(var(--base-margin) * 1.5);
display: grid;
/* Define grid columns: Auto label, flexible control, auto button */
grid-template-columns: auto minmax(0, 1fr) auto; /* Let label width adjust */
align-items: center; /* Vertically center items in the row */
gap: calc(var(--base-margin) * 1.5); /* Increase gap slightly for better spacing */
}
/* Special class for full-width elements like textarea */
.form-group.full-width-group, .error-message.full-width-group {
grid-column: 1 / -1; /* Span all columns */
gap: calc(var(--base-margin) / 2); /* Smaller gap for label/textarea */
}
.form-group.full-width-group label:not(.sr-only) { /* Adjust label if not screen-reader only */
grid-column: 1 / 2; /* Ensure label stays in its place if visible */
margin-bottom: calc(var(--base-margin) / 3);
}
/* Adjust grid for rows without a third element (like inline buttons) */
.form-group > *:nth-child(2):last-child {
grid-column: 2 / 4; /* Let the second element span if it's the last */
}
/* Specific adjustments for theme editor rows if needed */
section[v-if*="isEditingTheme"] .form-group {
/* Keep 2 columns for editor: auto label + flexible input */
grid-template-columns: auto 1fr;
margin-bottom: var(--base-margin);
gap: var(--base-margin); /* Keep editor gap smaller */
}
.form-group label {
grid-column: 1 / 2;
text-align: left; /* Keep text aligned to the left */
/* justify-self: start; Removed */
padding-right: 0; /* Ensure no right padding interferes */
padding-left: 0; /* Ensure no left padding interferes */
color: var(--text-color);
font-size: 0.9rem;
margin-bottom: 0;
/* white-space: nowrap; Removed in case it affects alignment */
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500; /* 稍微加粗标签 */
/* Ensure it behaves predictably within the grid cell */
display: block; /* Or inline-block if needed, but block is usually fine */
width: 100%; /* Occupy the auto-calculated width */
}
/* 输入控件 */
.form-group input[type="color"],
.form-group input[type="text"].text-input,
.form-group input[type="number"].number-input, /* <-- 添加 number-input */
.form-group select {
grid-column: 2 / 3;
border: 1px solid var(--border-color);
padding: 0.5rem 0.7rem;
border-radius: 4px;
font-size: 0.9rem;
background-color: var(--app-bg-color);
color: var(--text-color);
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease; /* 添加过渡效果 */
}
.form-group input[type="color"] {
padding: 2px;
height: 34px;
min-width: 50px;
max-width: 70px;
justify-self: start;
border-radius: 4px; /* 保持圆角一致 */
}
.form-group input:focus, .form-group select:focus {
border-color: var(--link-active-color);
outline: 0;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15); /* 调整聚焦阴影 */
}
/* Input controls (text, color, select) */
.form-group input[type="color"],
.form-group input[type="text"].text-input,
.form-group input[type="number"].number-input, /* <-- 添加 number-input */
.form-group select {
grid-column: 2 / 3; /* Place in the second column */
/* Existing styles below... */
border: 1px solid var(--border-color);
padding: 0.5rem 0.7rem;
border-radius: 4px;
font-size: 0.9rem;
background-color: var(--app-bg-color);
color: var(--text-color);
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease; /* 添加过渡效果 */
}
.form-group input[type="color"] {
padding: 2px;
height: 34px;
min-width: 50px;
max-width: 70px;
justify-self: start;
border-radius: 4px; /* 保持圆角一致 */
}
.form-group input[type="number"].number-input {
max-width: 100px; /* 限制数字输入框宽度 */
justify-self: start;
}
.form-group input:focus, .form-group select:focus, .form-group input[type="number"]:focus { /* <-- 添加 number input focus */
border-color: var(--link-active-color);
outline: 0;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15); /* 调整聚焦阴影 */
}
/* Style for JSON Textarea */
.json-textarea {
width: 100%;
font-family: var(--font-family-monospace); /* Use monospace font */
font-size: 0.9em;
line-height: 1.4;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.8rem;
background-color: var(--input-bg-color, var(--app-bg-color));
color: var(--text-color);
resize: vertical;
min-height: 200px; /* Ensure decent minimum height */
box-sizing: border-box;
white-space: pre-wrap; /* 确保换行符和空格被保留,并允许文本换行 */
overflow-wrap: break-word; /* 确保长单词在需要时能断开 */
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.json-textarea:focus {
border-color: var(--link-active-color);
outline: 0;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15);
}
/* Inline buttons within form-group */
.form-group .button-inline,
.form-group .button-danger {
grid-column: 3 / 4; /* Place in the third column */
margin-left: 0; /* Reset margin if needed */
padding: 0.4rem 0.8rem;
font-size: 0.9rem;
border: 1px solid var(--border-color);
white-space: nowrap;
justify-self: start;
border-radius: 4px; /* 统一圆角 */
transition: background-color 0.2s ease, border-color 0.2s ease; /* 添加过渡 */
}
.button-inline {
background-color: var(--header-bg-color); /* Default inline button style */
color: var(--text-color);
}
.form-group .button-inline:hover:not(:disabled) {
background-color: var(--border-color);
border-color: var(--text-color-secondary);
}
/* Danger button specific styles remain */
.form-group .button-danger {
background-color: #f8d7da;
color: #842029;
border-color: #f5c2c7;
}
.form-group .button-danger:hover:not(:disabled) {
background-color: #f1aeb5;
border-color: #ec8a98;
}
.form-group .button-danger:disabled,
.form-group .button-inline:disabled {
opacity: 0.6;
cursor: not-allowed;
}
hr {
border: none;
border-top: 1px solid var(--border-color);
margin: calc(var(--base-padding) * 2) 0;
}
/* Theme Management Styles */
.theme-management-buttons {
margin-top: var(--base-padding);
margin-bottom: calc(var(--base-padding) * 1.5);
display: flex;
gap: var(--base-margin);
flex-wrap: wrap;
padding-bottom: var(--base-padding);
border-bottom: 1px dashed var(--border-color);
}
/* Apply unified styles to theme management buttons - matching button-inline */
.theme-management-buttons button {
padding: 0.4rem 0.8rem; /* Match button-inline */
font-size: 0.9rem; /* Match button-inline */
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--header-bg-color); /* Match button-inline */
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
white-space: nowrap; /* Match button-inline */
}
.theme-management-buttons button:hover:not(:disabled) {
background-color: var(--border-color); /* Match button-inline hover */
border-color: var(--text-color-secondary);
}
.theme-management-buttons button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.theme-list {
list-style: none;
padding: 0;
margin-top: 0;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--app-bg-color);
}
.theme-list li {
display: grid; /* 使用 Grid 布局列表项 */
grid-template-columns: 1fr auto; /* 名称弹性,按钮固定 */
align-items: center;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.95rem;
transition: background-color 0.2s ease;
gap: var(--base-margin); /* 列间距 */
}
.theme-list li:hover {
background-color: var(--header-bg-color);
}
.theme-list li:last-child {
border-bottom: none;
}
.theme-list li span { /* 主题名称 */
grid-column: 1 / 2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-list li.preset-theme span {
font-style: italic;
color: var(--text-color-secondary);
}
.theme-actions {
grid-column: 2 / 3; /* 按钮组在第二列 */
flex-shrink: 0;
display: flex;
gap: 0.5rem;
}
/* Apply unified styles to theme action buttons - matching button-inline */
.theme-actions button {
margin-left: 0;
padding: 0.4rem 0.8rem; /* Corrected padding to match button-inline */
font-size: 0.9rem; /* Corrected font size to match button-inline */
border: 1px solid var(--border-color);
border-radius: 4px; /* Match border-radius */
background-color: var(--header-bg-color); /* Default style */
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
white-space: nowrap;
}
.theme-actions button:hover:not(:disabled) {
background-color: var(--border-color);
border-color: var(--text-color-secondary);
}
/* Specific style for danger button within theme actions */
/* Specific style for danger button within theme actions */
.theme-actions button.button-danger {
/* Danger colors override default */
background-color: #f8d7da;
color: #842029;
border-color: #f5c2c7;
/* Size and padding are inherited from the base .theme-actions button rule */
}
.theme-actions button.button-danger:hover:not(:disabled) {
/* Danger hover overrides default hover */
background-color: #f1aeb5;
border-color: #ec8a98;
}
.theme-actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.editor-footer {
margin-top: var(--base-padding);
display: flex;
justify-content: flex-end;
gap: var(--base-margin);
padding-top: var(--base-padding);
border-top: 1px solid var(--border-color);
}
.editor-footer button { /* 确保页脚按钮样式统一 */
padding: 0.6rem 1.2rem;
border-radius: 4px;
font-weight: bold;
}
/* Background Styles */
.background-preview {
width: 100%;
height: 150px;
border: 1px dashed var(--border-color);
margin-bottom: var(--base-margin);
display: flex;
justify-content: center;
align-items: center;
color: var(--text-color-secondary);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 4px;
background-color: var(--header-bg-color);
position: relative;
overflow: hidden; /* 防止背景图溢出圆角 */
}
.background-preview span {
background-color: rgba(255, 255, 255, 0.8); /* 增加背景不透明度 */
padding: 0.4rem 0.8rem; /* 增加内边距 */
border-radius: 3px;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color); /* 使用主文本颜色 */
box-shadow: 0 1px 2px rgba(0,0,0,0.1); /* 添加轻微阴影 */
}
.background-controls {
display: flex;
gap: var(--base-margin);
margin-bottom: var(--base-padding);
flex-wrap: wrap;
align-items: center;
}
/* Style default buttons in background-controls - matching button-inline */
.background-controls button:not(.button-danger) {
padding: 0.4rem 0.8rem; /* Match button-inline */
font-size: 0.9rem; /* Match button-inline */
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--header-bg-color); /* Match button-inline */
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
white-space: nowrap; /* Match button-inline */
}
.background-controls button:not(.button-danger):hover:not(:disabled) {
background-color: var(--border-color); /* Match button-inline hover */
border-color: var(--text-color-secondary);
}
/* Ensure danger button styles override default if necessary */
.background-controls button.button-danger {
/* Keep specific danger styles */
padding: 0.4rem 0.8rem; /* Match padding */
font-size: 0.9rem; /* Match font size */
border-radius: 4px; /* Match border radius */
white-space: nowrap; /* Match button-inline */
background-color: #f8d7da; /* 淡红色背景 */
color: #842029; /* 深红色文字 */
border-color: #f5c2c7; /* 边框颜色 */
}
.background-controls button.button-danger:hover:not(:disabled) {
background-color: #f1aeb5;
border-color: #ec8a98;
}
.background-controls button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
color: #842029;
background-color: #f8d7da;
border: 1px solid #f5c2c7;
padding: calc(var(--base-padding) * 0.75);
border-radius: 4px;
margin-top: var(--base-margin);
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
/* grid-column: 1 / -1; /* Let error messages flow naturally */
}
.error-message.full-width-group { /* Ensure full-width error messages span */
grid-column: 1 / -1;
}
.panel-footer {
display: flex;
justify-content: flex-end;
padding: var(--base-padding);
border-top: 1px solid var(--border-color);
background-color: var(--footer-bg-color);
flex-shrink: 0;
}
.panel-footer button {
padding: 0.6rem 1.2rem;
border-radius: 4px;
cursor: pointer;
margin-left: var(--base-margin);
border: 1px solid transparent;
font-weight: bold;
}
.panel-footer .button-primary {
background-color: var(--button-bg-color);
color: var(--button-text-color);
border-color: var(--button-bg-color);
}
.panel-footer .button-primary:hover:not(:disabled) {
background-color: var(--button-hover-bg-color);
border-color: var(--button-hover-bg-color);
}
.panel-footer .button-secondary {
background-color: var(--header-bg-color);
color: var(--text-color);
border-color: var(--border-color);
}
.panel-footer .button-secondary:hover:not(:disabled) {
background-color: var(--border-color);
}
.panel-footer button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>