This commit is contained in:
Baobhan Sith
2025-04-17 20:26:30 +08:00
parent 09cba0b3d3
commit 9eb0bcc5f3
40 changed files with 2607 additions and 326 deletions
+13 -14
View File
@@ -2,8 +2,8 @@
import { RouterLink, RouterView } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from './stores/auth.store';
import { useSettingsStore } from './stores/settings.store'; // 导入设置 Store
import { ref } from 'vue'; // 导入 ref
import { useSettingsStore } from './stores/settings.store';
import { useAppearanceStore } from './stores/appearance.store'; // 导入外观 Store
import { storeToRefs } from 'pinia';
// 导入通知显示组件
import UINotificationDisplay from './components/UINotificationDisplay.vue';
@@ -14,25 +14,24 @@ import StyleCustomizer from './components/StyleCustomizer.vue';
const { t } = useI18n();
const authStore = useAuthStore();
const settingsStore = useSettingsStore(); // 实例化设置 Store
const { isAuthenticated } = storeToRefs(authStore); // 获取登录状态
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore); // 获取弹窗编辑器设置
// 控制样式自定义器可见性状态
const isStyleCustomizerVisible = ref(false);
const settingsStore = useSettingsStore();
const appearanceStore = useAppearanceStore(); // 实例化外观 Store
const { isAuthenticated } = storeToRefs(authStore);
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore); // 从外观 store 获取可见性状态
const handleLogout = () => {
authStore.logout();
};
// 打开样式自定义器
// 打开样式自定义器的方法现在直接调用 store action
const openStyleCustomizer = () => {
isStyleCustomizerVisible.value = true;
appearanceStore.toggleStyleCustomizer(true);
};
// 关闭样式自定义器 (由子组件触发)
// 关闭样式自定义器的方法现在也调用 store action
const closeStyleCustomizer = () => {
isStyleCustomizerVisible.value = false;
appearanceStore.toggleStyleCustomizer(false);
};
</script>
@@ -48,7 +47,7 @@ const closeStyleCustomizer = () => {
<RouterLink to="/notifications">{{ t('nav.notifications') }}</RouterLink> | <!-- 新增通知链接 -->
<RouterLink to="/audit-logs">{{ t('nav.auditLogs') }}</RouterLink> | <!-- 新增审计日志链接 -->
<RouterLink to="/settings">{{ t('nav.settings') }}</RouterLink> | <!-- 新增设置链接 -->
<a href="#" @click.prevent="openStyleCustomizer" :title="t('nav.customizeStyle')">🎨</a> | <!-- 添加调色板按钮 -->
<a href="#" @click.prevent="openStyleCustomizer" :title="t('nav.customizeStyle')">🎨</a> | <!-- 点击调用 openStyleCustomizer -->
<RouterLink v-if="!isAuthenticated" to="/login">{{ t('nav.login') }}</RouterLink>
<a href="#" v-if="isAuthenticated" @click.prevent="handleLogout">{{ t('nav.logout') }}</a>
</nav>
@@ -64,7 +63,7 @@ const closeStyleCustomizer = () => {
<!-- 根据设置条件渲染全局文件编辑器弹窗 -->
<FileEditorOverlay v-if="showPopupFileEditorBoolean" />
<!-- 条件渲染样式自定义器 -->
<!-- 条件渲染样式自定义器使用 store 的状态和方法 -->
<StyleCustomizer v-if="isStyleCustomizerVisible" @close="closeStyleCustomizer" />
<footer>
@@ -1,65 +1,356 @@
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue';
import { ref, reactive, onMounted, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSettingsStore } from '../stores/settings.store';
import { useAppearanceStore } from '../stores/appearance.store'; // 使用新的 store
import { storeToRefs } from 'pinia';
import type { ITheme } from 'xterm'; // 导入 xterm 主题类型
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 settingsStore = useSettingsStore();
const { currentUiTheme, currentXtermTheme } = storeToRefs(settingsStore); // 获取响应式的主题状态
const appearanceStore = useAppearanceStore();
const {
currentUiTheme,
// currentTerminalTheme, // 这个是计算属性,只读,在编辑时不需要直接用
activeTerminalThemeId,
availableTerminalThemes,
currentTerminalFontFamily,
pageBackgroundImage,
pageBackgroundOpacity,
terminalBackgroundImage,
terminalBackgroundOpacity,
} = storeToRefs(appearanceStore);
// 创建本地响应式副本用于编辑
// --- 本地状态用于编辑 ---
const editableUiTheme = ref<Record<string, string>>({});
const editableXtermTheme = ref<ITheme>({});
const editableTerminalFontFamily = ref('');
const editablePageBackgroundOpacity = ref(1.0);
const editableTerminalBackgroundOpacity = ref(1.0);
// 初始化本地副本
const initializeEditableThemes = () => {
// 使用深拷贝确保不直接修改 store 状态
// 终端主题管理相关状态
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); // 用于显示保存主题时的错误
// 初始化本地编辑状态
const initializeEditableState = () => {
// 深拷贝 UI 主题
editableUiTheme.value = JSON.parse(JSON.stringify(currentUiTheme.value || {}));
editableXtermTheme.value = JSON.parse(JSON.stringify(currentXtermTheme.value || {}));
editableTerminalFontFamily.value = currentTerminalFontFamily.value;
selectedTerminalThemeId.value = activeTerminalThemeId.value ?? null; // 初始化下拉框
editablePageBackgroundOpacity.value = pageBackgroundOpacity.value;
editableTerminalBackgroundOpacity.value = terminalBackgroundOpacity.value;
// 不在 store 变化时重置编辑状态,除非是显式取消或保存
uploadError.value = null;
importError.value = null;
saveThemeError.value = null;
};
onMounted(initializeEditableThemes);
onMounted(initializeEditableState);
// 如果 store 中的主题变化(例如通过重置),也更新本地副本
watch(currentUiTheme, initializeEditableThemes, { deep: true });
watch(currentXtermTheme, initializeEditableThemes, { deep: true });
// 监听 store 变化以更新本地状态 (例如重置或外部更改)
// 只监听不需要编辑的状态或用于初始化的状态
watch([
currentUiTheme, currentTerminalFontFamily, activeTerminalThemeId,
pageBackgroundOpacity, terminalBackgroundOpacity
], (newVals, oldVals) => {
// 仅当非编辑状态时,或活动主题ID变化时,才同步下拉框和非编辑状态
if (!isEditingTheme.value || newVals[2] !== oldVals[2]) {
initializeEditableState();
} else {
// 如果正在编辑,只更新非编辑相关的部分 (例如 UI 主题可以在编辑终端主题时同时更新)
editableUiTheme.value = JSON.parse(JSON.stringify(newVals[0] || {}));
editableTerminalFontFamily.value = newVals[1];
editablePageBackgroundOpacity.value = newVals[3];
editableTerminalBackgroundOpacity.value = newVals[4];
}
}, { deep: true });
const emit = defineEmits(['close']);
const closeCustomizer = () => {
emit('close');
// 如果正在编辑主题,提示用户是否放弃更改
if (isEditingTheme.value) {
if (confirm(t('styleCustomizer.confirmCloseEditing'))) {
isEditingTheme.value = false; // 退出编辑状态
editingTheme.value = null;
emit('close');
}
} else {
emit('close');
}
};
// 临时的编辑区域占位符
const currentTab = ref<'ui' | 'terminal'>('ui');
// 当前活动的标签页
const currentTab = ref<'ui' | 'terminal' | 'background'>('ui');
// --- 处理函数 ---
const handleSaveChanges = async () => {
// 保存 UI 主题更改
const handleSaveUiTheme = async () => {
try {
await settingsStore.saveCustomThemes(editableUiTheme.value, editableXtermTheme.value);
// 可以添加一个成功提示
closeCustomizer(); // 保存后关闭
} catch (error) {
console.error("保存主题失败:", error);
// 可以添加一个错误提示
await appearanceStore.saveCustomUiTheme(editableUiTheme.value);
alert(t('styleCustomizer.uiThemeSaved')); // 简单提示
} catch (error: any) {
console.error("保存 UI 主题失败:", error);
alert(t('styleCustomizer.uiThemeSaveFailed', { message: error.message }));
}
};
const handleResetDefault = async () => {
try {
await settingsStore.resetCustomThemes();
// 重置后本地副本会自动通过 watch 更新
// 可以添加一个成功提示
} catch (error) {
console.error("重置主题失败:", error);
// 可以添加一个错误提示
}
// 重置 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 }));
}
}
};
// 辅助函数:将 CSS 变量名转换为更友好的标签
// 保存终端字体
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 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 }));
}
}
};
const handlePageOpacityChange = async () => {
try {
await appearanceStore.setPageBackgroundOpacity(editablePageBackgroundOpacity.value);
} catch (error: any) {
console.error("设置页面背景透明度失败:", error);
// 恢复旧值
editablePageBackgroundOpacity.value = pageBackgroundOpacity.value;
alert(t('styleCustomizer.setOpacityFailed', { message: error.message }));
}
};
const handleTerminalOpacityChange = async () => {
try {
await appearanceStore.setTerminalBackgroundOpacity(editableTerminalBackgroundOpacity.value);
} catch (error: any) {
console.error("设置终端背景透明度失败:", error);
editableTerminalBackgroundOpacity.value = terminalBackgroundOpacity.value;
alert(t('styleCustomizer.setOpacityFailed', { message: error.message }));
}
};
// --- 辅助函数 ---
// 格式化 UI 主题标签
const formatLabel = (key: string): string => {
// 简单的转换逻辑,可以根据需要优化
return key
@@ -69,12 +360,11 @@ const formatLabel = (key: string): string => {
.replace(/^./, (str) => str.toUpperCase()); // 首字母大写
};
// 辅助函数:将 xterm theme key 转换为更友好的标签
// 格式化 xterm 主题属性标签
const formatXtermLabel = (key: keyof ITheme): string => {
// 简单的转换逻辑
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase());
};
</script>
<template>
@@ -89,9 +379,12 @@ const formatXtermLabel = (key: keyof ITheme): string => {
<button @click="currentTab = 'ui'" :class="{ active: currentTab === 'ui' }">
{{ t('styleCustomizer.uiStyles') }}
</button>
<button @click="currentTab = 'terminal'" :class="{ active: currentTab === 'terminal' }">
<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>
</nav>
<main class="panel-main">
<section v-if="currentTab === 'ui'">
@@ -117,34 +410,132 @@ const formatXtermLabel = (key: keyof ITheme): string => {
/>
</div>
</section>
<section v-if="currentTab === 'terminal'">
<section v-if="currentTab === 'terminal' && !isEditingTheme">
<h3>{{ t('styleCustomizer.terminalStyles') }}</h3>
<p>{{ t('styleCustomizer.terminalDescription') }}</p>
<!-- 动态生成终端样式编辑控件 -->
<div v-for="(value, key) in editableXtermTheme" :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="(editableXtermTheme as any)[key]"
/>
<!-- 其他类型如数字布尔值可以添加相应控件这里简化为文本 -->
<input
v-else
type="text"
:id="`xterm-${key}`"
v-model="(editableXtermTheme as any)[key]"
class="text-input"
/>
<!-- 终端字体设置 -->
<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>
<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>
<div class="form-group">
<label for="pageBgOpacity">{{ t('styleCustomizer.pageBgOpacity') }}:</label>
<input type="range" id="pageBgOpacity" v-model.number="editablePageBackgroundOpacity" min="0" max="1" step="0.05" @change="handlePageOpacityChange"/>
<span>{{ Math.round(editablePageBackgroundOpacity * 100) }}%</span>
</div>
<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>
<div class="form-group">
<label for="terminalBgOpacity">{{ t('styleCustomizer.terminalBgOpacity') }}:</label>
<input type="range" id="terminalBgOpacity" v-model.number="editableTerminalBackgroundOpacity" min="0" max="1" step="0.05" @change="handleTerminalOpacityChange"/>
<span>{{ Math.round(editableTerminalBackgroundOpacity * 100) }}%</span>
</div>
</section>
</main>
</div>
<footer class="panel-footer">
<button @click="handleResetDefault" class="button-secondary">{{ t('styleCustomizer.resetDefault') }}</button>
<button @click="handleSaveChanges" class="button-primary">{{ t('styleCustomizer.saveChanges') }}</button>
<!-- 根据当前 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>
@@ -210,6 +601,7 @@ const formatXtermLabel = (key: keyof ITheme): string => {
border-right: 1px solid var(--border-color, #ccc);
padding: var(--base-padding, 1rem);
background-color: var(--header-bg-color, #f0f0f0); /* 轻微区分背景 */
flex-shrink: 0; /* 防止导航栏被压缩 */
}
.panel-nav button {
@@ -234,6 +626,13 @@ const formatXtermLabel = (key: keyof ITheme): string => {
color: var(--button-text-color, #fff);
font-weight: bold;
}
.panel-nav button:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: transparent !important; /* 确保禁用时背景透明 */
color: var(--text-color-secondary, #999); /* 禁用时文字颜色变灰 */
}
.panel-main {
flex-grow: 1;
@@ -247,6 +646,11 @@ const formatXtermLabel = (key: keyof ITheme): string => {
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.panel-main h4 {
margin-top: 1.5rem;
margin-bottom: 0.8rem;
color: var(--text-color);
}
.panel-main p {
color: var(--text-color-secondary);
@@ -254,34 +658,65 @@ const formatXtermLabel = (key: keyof ITheme): string => {
margin-bottom: 1.5rem;
}
.form-group {
.setting-description {
font-size: 0.85rem;
color: var(--text-color-secondary);
margin-top: -0.5rem; /* 减少与上方元素的间距 */
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
display: flex; /* 使用 flex 布局 */
align-items: center; /* 垂直居中对齐 */
flex-wrap: wrap; /* 允许换行 */
}
.form-group label {
display: inline-block;
/* display: inline-block; */
min-width: 150px; /* 调整标签最小宽度以适应更长的文本 */
margin-right: 0.5rem;
vertical-align: middle;
/* vertical-align: middle; */
text-align: right; /* 标签右对齐 */
padding-right: 5px; /* 标签和输入框间距 */
flex-shrink: 0; /* 防止标签被压缩 */
}
.form-group input[type="color"] {
vertical-align: middle;
/* vertical-align: middle; */
border: 1px solid var(--border-color);
padding: 2px;
cursor: pointer;
width: 150px; /* 统一输入框宽度 */
height: 30px; /* 增加高度 */
}
.form-group input[type="text"].text-input {
vertical-align: middle;
/* vertical-align: middle; */
border: 1px solid var(--border-color);
padding: 4px 6px;
border-radius: 3px;
width: 150px; /* 统一文本输入框宽度 */
flex-grow: 1; /* 允许输入框扩展 */
min-width: 100px; /* 最小宽度 */
}
.form-group input[type="text"].wide-input {
/* width: calc(100% - 200px); */ /* 调整宽度以适应按钮 */
/* min-width: 200px; */
flex-grow: 1; /* 占据更多空间 */
}
.form-group select {
padding: 4px 6px;
border: 1px solid var(--border-color);
border-radius: 3px;
flex-grow: 1;
min-width: 150px;
}
.form-group input[type="range"] {
flex-grow: 1;
margin: 0 10px;
}
.panel-footer {
display: flex;
@@ -318,4 +753,113 @@ const formatXtermLabel = (key: keyof ITheme): string => {
background-color: #5a6268;
border-color: #545b62;
}
.button-inline {
margin-left: 10px;
padding: 4px 8px;
/* vertical-align: middle; */
flex-shrink: 0; /* 防止按钮被压缩 */
}
hr {
border: none;
border-top: 1px solid var(--border-color, #eee);
margin: 1.5rem 0;
}
/* Theme Management Styles */
.theme-management-buttons {
margin-top: 1rem;
margin-bottom: 1rem;
display: flex;
gap: 10px; /* 按钮间距 */
flex-wrap: wrap; /* 允许换行 */
}
.theme-management-buttons button {
padding: 0.5rem 1rem;
}
.theme-list {
list-style: none;
padding: 0;
margin-top: 1rem;
max-height: 200px; /* 限制列表高度并滚动 */
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.theme-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.theme-list li:last-child {
border-bottom: none;
}
.theme-list li.preset-theme span {
font-style: italic;
color: var(--text-color-secondary);
}
.theme-actions {
flex-shrink: 0; /* 防止按钮组被压缩 */
}
.theme-actions button {
margin-left: 0.5rem;
padding: 2px 6px;
font-size: 0.8rem;
}
.button-danger {
background-color: #dc3545;
color: white;
border-color: #dc3545;
}
.button-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.button-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.editor-footer {
margin-top: 1.5rem;
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Background Styles */
.background-preview {
width: 100%;
height: 100px;
border: 1px dashed var(--border-color);
margin-bottom: 10px;
display: flex;
justify-content: center;
align-items: center;
color: var(--text-color-secondary);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.background-controls {
display: flex;
gap: 10px;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.background-controls button {
padding: 0.5rem 1rem;
}
.error-message {
color: red;
font-size: 0.9rem;
margin-top: 5px;
width: 100%; /* 确保错误消息占满宽度 */
}
</style>
+110 -22
View File
@@ -2,7 +2,7 @@
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { ITheme } from 'xterm';
import { Terminal } from 'xterm';
import { useSettingsStore } from '../stores/settings.store'; // 导入设置 store
import { useAppearanceStore } from '../stores/appearance.store'; // 导入外观 store
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
@@ -27,25 +27,25 @@ let terminal: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
let debounceTimer: number | null = null; // 用于防抖的计时器 ID
const fontSize = ref(14); // 字体大小状态, 默认为14
const fontSize = ref(14); // 字体大小状态, 默认为14 (这个可以保留,或者也移到 appearance store)
// --- Settings Store ---
const settingsStore = useSettingsStore();
const { currentXtermTheme } = storeToRefs(settingsStore); // 获取响应式的 xterm 主题
// --- Appearance Store ---
const appearanceStore = useAppearanceStore();
const { currentTerminalTheme, currentTerminalFontFamily, terminalBackgroundImage, terminalBackgroundOpacity } = storeToRefs(appearanceStore); // 获取外观状态
// 防抖函数
const debounce = (func: Function, delay: number) => {
let timeoutId: number | null = null; // Use a local variable for the timeout ID
return (...args: any[]) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
debounceTimer = window.setTimeout(() => {
timeoutId = window.setTimeout(() => {
func(...args);
debounceTimer = null;
timeoutId = null;
}, delay);
};
};
// 防抖处理由 ResizeObserver 触发的 resize 事件
const debouncedEmitResize = debounce((term: Terminal) => {
if (term && props.isActive) { // 仅当标签仍处于活动状态时才发送防抖后的 resize
@@ -75,9 +75,9 @@ onMounted(() => {
if (terminalRef.value) {
terminal = new Terminal({
cursorBlink: true,
fontSize: fontSize.value,
fontFamily: 'Consolas, "Courier New", monospace, "Microsoft YaHei", "微软雅黑"',
theme: currentXtermTheme.value, // *** 使用 store 中的当前 xterm 主题 ***
fontSize: fontSize.value, // 初始字体大小
fontFamily: currentTerminalFontFamily.value, // 使用 store 中的字体设置
theme: currentTerminalTheme.value, // 使用 store 中的当前 xterm 主题
rows: 24, // 初始行数
cols: 80, // 初始列数
allowTransparency: true,
@@ -185,19 +185,35 @@ onMounted(() => {
emit('ready', { sessionId: props.sessionId, terminal: terminal });
}
// --- 监听 xterm 主题变化 ---
watch(currentXtermTheme, (newTheme) => {
// --- 监听外观变化 ---
watch(currentTerminalTheme, (newTheme) => {
if (terminal) {
console.log(`[Terminal ${props.sessionId}] Applying new xterm theme.`); // 日志改为中文
console.log(`[Terminal ${props.sessionId}] 应用新终端主题。`);
terminal.options.theme = newTheme;
// 可能需要重新渲染或刷新终端以完全应用主题,但通常 xterm 会自动处理
// terminal.refresh(0, terminal.rows - 1); // 如果需要强制刷新
}
}, { deep: true }); // 使用 deep watch
}, { deep: true });
// 聚焦终端
terminal.focus();
watch(currentTerminalFontFamily, (newFontFamily) => {
if (terminal) {
console.log(`[Terminal ${props.sessionId}] 应用新终端字体: ${newFontFamily}`);
terminal.options.fontFamily = newFontFamily;
// 字体变化可能影响尺寸,重新 fit
fitAndEmitResizeNow(terminal);
}
});
// 监听背景图片和透明度 (恢复之前的监听方式,因为监听整个对象可能引入其他问题)
watch([terminalBackgroundImage, terminalBackgroundOpacity], () => {
console.log(`[Terminal Watcher] terminalBackgroundImage or Opacity changed. New image: ${terminalBackgroundImage.value}`); // 添加日志确认 watcher 触发
applyTerminalBackground();
}, { immediate: true }); // 添加 immediate: true,强制立即执行一次
// 移除 onMounted 中的 applyTerminalBackground 调用,完全依赖 watch
// applyTerminalBackground(); // 初始应用一次
// 聚焦终端 (添加 null check)
if (terminal) {
terminal.focus();
}
// 重新添加鼠标滚轮缩放功能
if (terminalRef.value) {
terminalRef.value.addEventListener('wheel', (event: WheelEvent) => {
@@ -264,6 +280,53 @@ const write = (data: string | Uint8Array) => {
};
defineExpose({ write });
// --- 应用终端背景 ---
const applyTerminalBackground = () => {
if (terminalRef.value) {
if (terminalBackgroundImage.value) {
// --- 修改开始 ---
// 使用环境变量获取后端基础 URL
const backendUrl = import.meta.env.VITE_API_BASE_URL || ''; // 提供一个默认空字符串以防万一
const imagePath = terminalBackgroundImage.value;
console.log(`[Terminal applyTerminalBackground] backendUrl: "${backendUrl}", imagePath: "${imagePath}"`); // 详细日志
const fullImageUrl = `${backendUrl}${imagePath}`;
console.log(`[Terminal applyTerminalBackground] fullImageUrl: "${fullImageUrl}"`); // 打印完整 URL
// --- 修改结束 ---
// --- 使用 nextTick 包装样式应用 ---
nextTick(() => {
if (terminalRef.value) { // 再次检查 ref 是否存在
terminalRef.value.style.backgroundImage = `url(${fullImageUrl})`;
terminalRef.value.style.backgroundSize = 'cover'; // Or 'contain', 'auto', etc.
terminalRef.value.style.backgroundPosition = 'center';
terminalRef.value.style.backgroundRepeat = 'no-repeat';
// 添加 CSS 类
terminalRef.value.classList.add('has-terminal-background');
}
});
// 应用透明度: 通过设置背景色实现,需要 xterm 的 allowTransparency: true
// 注意:这会影响整个终端的背景,包括文本后的背景
// 一个常见的做法是设置一个稍微透明的背景色,让图片透出来
// 例如,将 xterm 主题的 background 设置为 rgba(r, g, b, opacity)
// 这里我们简单设置容器的 opacity,但这会影响文本!更好的方法是修改主题。
// 另一种方法是用伪元素做背景层。
// 为了简单起见,我们暂时只设置背景图,透明度让用户在主题中调整 background 的 alpha 值。
// terminalRef.value.style.opacity = terminalBackgroundOpacity.value.toString(); // 不推荐直接设置 opacity
console.log(`[Terminal ${props.sessionId}] 应用终端背景图片: ${terminalBackgroundImage.value}`);
} else {
// --- 使用 nextTick 包装样式移除 ---
nextTick(() => {
if (terminalRef.value) { // 再次检查 ref 是否存在
terminalRef.value.style.backgroundImage = 'none';
// 移除 CSS 类
terminalRef.value.classList.remove('has-terminal-background');
}
});
// terminalRef.value.style.opacity = '1'; // 移除背景时恢复不透明
console.log(`[Terminal ${props.sessionId}] 移除终端背景图片。`);
}
}
};
</script>
<template>
@@ -276,7 +339,32 @@ defineExpose({ write });
width: 100%;
height: 100%; /* 高度需要由父容器控制 */
overflow: hidden; /* 阻止此容器本身产生滚动条 */
position: relative; /* 用于可能的伪元素背景 */
}
/* 移除 :deep 样式,让 xterm 内部自然处理滚动 */
/* 示例:使用伪元素添加带透明度的背景层 (如果需要独立于主题的透明度) */
/*
.terminal-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image: var(--terminal-bg-image);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: var(--terminal-bg-opacity);
z-index: -1; // 确保在 xterm 内容后面
}
*/
/* 当容器有背景图时,强制内部 xterm 视口和屏幕背景透明 */
.terminal-container.has-terminal-background :deep(.xterm-viewport),
.terminal-container.has-terminal-background :deep(.xterm-screen) {
background-color: transparent !important;
}
</style>
+98 -6
View File
@@ -14,13 +14,65 @@
"customizeStyle": "Customize Style"
},
"styleCustomizer": {
"title": "Style Customizer",
"title": "Appearance Customizer",
"uiStyles": "UI Styles",
"terminalStyles": "Terminal Styles",
"uiDescription": "Adjust colors, fonts, etc., for the application interface.",
"backgroundSettings": "Background",
"uiDescription": "Adjust colors, fonts, etc. for the application interface.",
"terminalDescription": "Customize the color scheme and font for the terminal.",
"resetDefault": "Reset Default",
"saveChanges": "Save Changes"
"resetDefault": "Reset to Default",
"saveChanges": "Save Changes",
"resetUiTheme": "Reset UI Theme",
"saveUiTheme": "Save UI Theme",
"terminalFontFamily": "Terminal Font",
"terminalFontPlaceholder": "e.g., \"Fira Code\", Consolas, monospace",
"terminalFontDescription": "Enter font names, separated by commas. Use quotes for names with spaces.",
"terminalThemeSelection": "Terminal Theme",
"activeTheme": "Active Theme",
"selectThemePrompt": "Select a theme...",
"preset": "Preset",
"addNewTheme": "New Theme",
"importTheme": "Import Theme",
"exportTheme": "Export Selected",
"editThemeTitle": "Edit Terminal Theme",
"newThemeTitle": "New Terminal Theme",
"themeName": "Theme Name",
"confirmDeleteTheme": "Are you sure you want to delete the theme \"{name}\"? This action cannot be undone.",
"confirmCloseEditing": "You are currently editing a theme. Closing will discard unsaved changes. Are you sure?",
"errorThemeNameRequired": "Theme name cannot be empty.",
"themeUpdatedSuccess": "Theme updated successfully.",
"themeCreatedSuccess": "Theme created successfully.",
"themeSaveFailed": "Failed to save theme.",
"themeDeletedSuccess": "Theme deleted successfully.",
"themeDeleteFailed": "Failed to delete theme: {message}",
"importSuccess": "Theme imported successfully.",
"importFailed": "Theme import failed.",
"exportFailed": "Failed to export theme: {message}",
"pageBackground": "Page Background",
"terminalBackground": "Terminal Background",
"noBackground": "No background",
"uploadPageBg": "Upload Page Bg",
"removePageBg": "Remove Page Bg",
"uploadTerminalBg": "Upload Terminal Bg",
"removeTerminalBg": "Remove Terminal Bg",
"pageBgOpacity": "Page Bg Opacity",
"terminalBgOpacity": "Terminal Bg Opacity",
"uploadFailed": "Upload failed: {message}",
"pageBgUploadSuccess": "Page background uploaded successfully.",
"terminalBgUploadSuccess": "Terminal background uploaded successfully.",
"confirmRemovePageBg": "Are you sure you want to remove the page background image?",
"confirmRemoveTerminalBg": "Are you sure you want to remove the terminal background image?",
"pageBgRemoved": "Page background removed.",
"terminalBgRemoved": "Terminal background removed.",
"removeBgFailed": "Failed to remove background: {message}",
"setOpacityFailed": "Failed to set opacity: {message}",
"uiThemeSaved": "UI theme saved.",
"uiThemeSaveFailed": "Failed to save UI theme: {message}",
"uiThemeReset": "UI theme reset to default.",
"uiThemeResetFailed": "Failed to reset UI theme: {message}",
"terminalFontSaved": "Terminal font saved.",
"terminalFontSaveFailed": "Failed to save terminal font: {message}",
"setActiveThemeFailed": "Failed to set active terminal theme: {message}"
},
"login": {
"title": "User Login",
@@ -308,7 +360,7 @@
},
"inputPlaceholder": "Type to search or create tags...",
"removeSelection": "Remove this tag selection",
"deleteTagGlobally": "Delete this tag globally"
"deleteTagGlobally": "Delete this tag globally"
},
"settings": {
"title": "Settings",
@@ -491,6 +543,11 @@
"IP_BLACKLISTED": "IP Blacklisted",
"SERVER_ERROR": "Server Error"
}
},
"appearance": {
"title": "Appearance Settings",
"description": "Customize the visual theme and background of the application.",
"customizeButton": "Customize Appearance"
}
},
"common": {
@@ -575,7 +632,42 @@
"commandBar": "Command Bar",
"fileManager": "File Manager",
"editor": "Editor",
"statusMonitor": "Status Monitor"
"statusMonitor": "Status Monitor",
"commandHistory": "Command History",
"quickCommands": "Quick Commands"
}
},
"commandHistory": {
"title": "Command History",
"searchPlaceholder": "Search history...",
"clear": "Clear",
"copy": "Copy",
"delete": "Delete",
"loading": "Loading...",
"empty": "No history records",
"confirmClear": "Are you sure you want to clear all history?",
"copied": "Copied to clipboard",
"copyFailed": "Copy failed"
},
"quickCommands": {
"title": "Quick Commands",
"searchPlaceholder": "Search name or command...",
"add": "Add",
"sortBy": "Sort by:",
"sortByName": "Name",
"sortByUsage": "Usage Frequency",
"usageCount": "Usage Count",
"empty": "No quick commands. Click 'Add' to create one!",
"confirmDelete": "Are you sure you want to delete the quick command \"{name}\"?",
"form": {
"titleAdd": "Add Quick Command",
"titleEdit": "Edit Quick Command",
"name": "Name:",
"namePlaceholder": "Optional, for quick identification",
"command": "Command:",
"commandPlaceholder": "e.g., ls -alh /home/user",
"errorCommandRequired": "Command cannot be empty",
"add": "Add"
}
}
}
+63 -4
View File
@@ -11,16 +11,68 @@
"notifications": "通知管理",
"auditLogs": "审计日志",
"settings": "设置",
"customizeStyle": "自定义样式"
"customizeStyle": "自定义外观"
},
"styleCustomizer": {
"title": "样式自定义",
"title": "外观自定义",
"uiStyles": "界面样式",
"terminalStyles": "终端样式",
"backgroundSettings": "背景设置",
"uiDescription": "调整应用程序界面的颜色、字体等。",
"terminalDescription": "自定义终端的颜色方案和字体。",
"resetDefault": "恢复默认",
"saveChanges": "保存更改"
"saveChanges": "保存更改",
"resetUiTheme": "重置界面主题",
"saveUiTheme": "保存界面主题",
"terminalFontFamily": "终端字体",
"terminalFontPlaceholder": "例如:\"Fira Code\", Consolas, monospace",
"terminalFontDescription": "输入字体名称,用英文逗号分隔。如果字体名称包含空格,请用引号括起来。",
"terminalThemeSelection": "终端主题",
"activeTheme": "当前主题",
"selectThemePrompt": "选择一个主题...",
"preset": "预设",
"addNewTheme": "新建主题",
"importTheme": "导入主题",
"exportTheme": "导出选中主题",
"editThemeTitle": "编辑终端主题",
"newThemeTitle": "新建终端主题",
"themeName": "主题名称",
"confirmDeleteTheme": "确定要删除主题 \"{name}\" 吗?此操作不可撤销。",
"confirmCloseEditing": "您正在编辑主题,关闭将丢失未保存的更改。确定要关闭吗?",
"errorThemeNameRequired": "主题名称不能为空。",
"themeUpdatedSuccess": "主题更新成功。",
"themeCreatedSuccess": "主题创建成功。",
"themeSaveFailed": "保存主题失败。",
"themeDeletedSuccess": "主题删除成功。",
"themeDeleteFailed": "删除主题失败: {message}",
"importSuccess": "主题导入成功。",
"importFailed": "主题导入失败。",
"exportFailed": "导出主题失败: {message}",
"pageBackground": "页面背景",
"terminalBackground": "终端背景",
"noBackground": "无背景",
"uploadPageBg": "上传页面背景",
"removePageBg": "移除页面背景",
"uploadTerminalBg": "上传终端背景",
"removeTerminalBg": "移除终端背景",
"pageBgOpacity": "页面背景不透明度",
"terminalBgOpacity": "终端背景不透明度",
"uploadFailed": "上传失败: {message}",
"pageBgUploadSuccess": "页面背景上传成功。",
"terminalBgUploadSuccess": "终端背景上传成功。",
"confirmRemovePageBg": "确定要移除页面背景图片吗?",
"confirmRemoveTerminalBg": "确定要移除终端背景图片吗?",
"pageBgRemoved": "页面背景已移除。",
"terminalBgRemoved": "终端背景已移除。",
"removeBgFailed": "移除背景失败: {message}",
"setOpacityFailed": "设置透明度失败: {message}",
"uiThemeSaved": "界面主题已保存。",
"uiThemeSaveFailed": "保存界面主题失败: {message}",
"uiThemeReset": "界面主题已重置为默认值。",
"uiThemeResetFailed": "重置界面主题失败: {message}",
"terminalFontSaved": "终端字体已保存。",
"terminalFontSaveFailed": "保存终端字体失败: {message}",
"setActiveThemeFailed": "设置激活终端主题失败: {message}"
},
"login": {
"title": "用户登录",
@@ -180,7 +232,9 @@
"noPassword": "连接配置缺少密码信息。",
"shellError": "打开 Shell 失败: {message}",
"alreadyConnected": "已存在活动的 SSH 连接。",
"unknown": "未知状态"
"unknown": "未知状态",
"wsClosedWillRetry": "WebSocket 连接已关闭,将在 {seconds} 秒后尝试第 {attempt} 次重连...",
"reconnecting": "正在尝试重新连接..."
},
"selectConnectionPrompt": "请选择一个连接",
"selectConnectionHint": "从左侧列表中选择一个连接,或点击'添加新连接'按钮创建一个新连接。",
@@ -494,6 +548,11 @@
"IP_BLACKLISTED": "IP 已被拉黑",
"SERVER_ERROR": "服务器错误"
}
},
"appearance": {
"title": "外观设置",
"description": "自定义应用程序的视觉主题和背景。",
"customizeButton": "自定义外观"
}
},
"common": {
+13 -6
View File
@@ -5,6 +5,7 @@ import App from './App.vue';
import router from './router'; // 引入我们创建的 router
import i18n from './i18n'; // 引入 i18n 实例
import { useSettingsStore } from './stores/settings.store'; // 引入 Settings Store
import { useAppearanceStore } from './stores/appearance.store'; // 引入 Appearance Store
import './style.css';
// 导入 Font Awesome CSS
import '@fortawesome/fontawesome-free/css/all.min.css';
@@ -20,12 +21,18 @@ app.use(pinia); // 使用配置好的 Pinia 实例
app.use(router); // 使用 Router
app.use(i18n); // 使用 i18n
// 在挂载应用前加载初始设置
const settingsStore = useSettingsStore(pinia); // 需要传递 pinia 实例
settingsStore.loadInitialSettings().then(() => {
app.mount('#app'); // 确保设置加载完成后再挂载
}).catch((error: unknown) => { // 为 error 添加 unknown 类型
console.error("Failed to load initial settings before mounting app:", error);
// 在挂载应用前加载初始设置和外观数据
const settingsStore = useSettingsStore(pinia);
const appearanceStore = useAppearanceStore(pinia); // 实例化 Appearance Store
Promise.all([
settingsStore.loadInitialSettings(),
appearanceStore.loadInitialAppearanceData() // 并行加载外观数据
]).then(() => {
console.log("初始设置和外观数据加载完成。");
app.mount('#app'); // 确保所有数据加载完成后再挂载
}).catch((error: unknown) => {
console.error("加载初始数据失败:", error);
// 即使加载失败,也尝试挂载应用,可能使用默认设置
app.mount('#app');
});
@@ -0,0 +1,456 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import { ref, computed, watch, nextTick } from 'vue'; // 导入 nextTick
import type { ITheme } from 'xterm';
import type { TerminalTheme } from '../../../backend/src/types/terminal-theme.types'; // 引用后端类型
import type { AppearanceSettings, UpdateAppearanceDto } from '../../../backend/src/types/appearance.types'; // 引用后端类型
import { defaultXtermTheme, defaultUiTheme } from './default-themes.js'; // 尝试添加 .js (编译后) 或保持 .ts
// Helper function to safely parse JSON
const safeJsonParse = <T>(jsonString: string | undefined | null, defaultValue: T): T => {
if (!jsonString) return defaultValue;
try {
return JSON.parse(jsonString);
} catch (e) {
console.error("JSON 解析失败:", e);
return defaultValue;
}
};
export const useAppearanceStore = defineStore('appearance', () => {
// --- State ---
const isLoading = ref(false);
const error = ref<string | null>(null);
const isStyleCustomizerVisible = ref(false); // 新增:控制样式编辑器可见性
// Appearance Settings State
const appearanceSettings = ref<Partial<AppearanceSettings>>({}); // 从 API 获取的原始设置
const availableTerminalThemes = ref<TerminalTheme[]>([]); // 终端主题列表
// --- Computed Properties (Getters) ---
// 当前应用的 UI 主题 (CSS 变量对象)
const currentUiTheme = computed<Record<string, string>>(() => {
return safeJsonParse(appearanceSettings.value.customUiTheme, defaultUiTheme);
});
// 当前激活的终端主题 ID
const activeTerminalThemeId = computed(() => appearanceSettings.value.activeTerminalThemeId);
// 当前应用的终端主题对象 (ITheme)
const currentTerminalTheme = computed<ITheme>(() => {
if (!activeTerminalThemeId.value || availableTerminalThemes.value.length === 0) {
return defaultXtermTheme; // 回退到默认
}
const activeTheme = availableTerminalThemes.value.find(t => t._id === activeTerminalThemeId.value);
return activeTheme ? activeTheme.themeData : defaultXtermTheme; // 找不到也回退
});
// 当前终端字体设置
const currentTerminalFontFamily = computed<string>(() => {
return appearanceSettings.value.terminalFontFamily || 'Consolas, "Courier New", monospace, "Microsoft YaHei", "微软雅黑"'; // 提供默认值
});
// 页面背景图片 URL
const pageBackgroundImage = computed(() => appearanceSettings.value.pageBackgroundImage);
// 页面背景透明度
const pageBackgroundOpacity = computed(() => appearanceSettings.value.pageBackgroundOpacity ?? 1.0); // 默认 1
// 终端背景图片 URL
const terminalBackgroundImage = computed(() => appearanceSettings.value.terminalBackgroundImage);
// 终端背景透明度
const terminalBackgroundOpacity = computed(() => appearanceSettings.value.terminalBackgroundOpacity ?? 1.0); // 默认 1
// --- Actions ---
/**
* 加载所有外观相关设置 (外观设置 + 终端主题列表)
*/
async function loadInitialAppearanceData() {
isLoading.value = true;
error.value = null;
try {
// 并行加载外观设置和主题列表
const [settingsResponse, themesResponse] = await Promise.all([
axios.get<AppearanceSettings>('/api/v1/appearance'),
axios.get<TerminalTheme[]>('/api/v1/terminal-themes')
]);
appearanceSettings.value = settingsResponse.data;
availableTerminalThemes.value = themesResponse.data;
console.log('[AppearanceStore] 外观设置已加载:', appearanceSettings.value);
console.log('[AppearanceStore] 终端主题列表已加载:', availableTerminalThemes.value);
// 应用加载的 UI 主题
applyUiTheme(currentUiTheme.value);
// 应用背景
applyPageBackground();
// 终端背景和主题将在 Terminal 组件中应用
} catch (err: any) {
console.error('加载外观数据失败:', err);
error.value = err.response?.data?.message || err.message || '加载外观数据失败';
// 出错时应用默认值
appearanceSettings.value = {}; // 清空可能不完整的设置
availableTerminalThemes.value = [];
applyUiTheme(defaultUiTheme);
applyPageBackground(); // 应用默认背景(可能为空)
} finally {
isLoading.value = false;
}
}
/**
* 切换样式编辑器面板的可见性。
* @param visible 可选,强制设置可见性
*/
function toggleStyleCustomizer(visible?: boolean) {
isStyleCustomizerVisible.value = visible === undefined ? !isStyleCustomizerVisible.value : visible;
console.log('[AppearanceStore] Style Customizer visibility toggled:', isStyleCustomizerVisible.value);
}
/**
* 更新外观设置 (不包括主题列表管理)
* @param updates 要更新的设置项
*/
async function updateAppearanceSettings(updates: UpdateAppearanceDto) {
try {
const response = await axios.put<AppearanceSettings>('/api/v1/appearance', updates);
// 使用后端返回的最新设置更新本地状态
appearanceSettings.value = response.data;
console.log('[AppearanceStore] 外观设置已更新:', appearanceSettings.value);
// 如果 UI 主题或背景更新,重新应用
if (updates.customUiTheme !== undefined) applyUiTheme(currentUiTheme.value);
if (updates.pageBackgroundImage !== undefined || updates.pageBackgroundOpacity !== undefined) applyPageBackground();
// 终端相关设置由 Terminal 组件监听应用
} catch (err: any) {
console.error('更新外观设置失败:', err);
throw new Error(err.response?.data?.message || err.message || '更新外观设置失败');
}
}
/**
* 保存当前编辑器中的自定义 UI 主题到后端。
* @param uiTheme UI 主题对象
*/
async function saveCustomUiTheme(uiTheme: Record<string, string>) {
await updateAppearanceSettings({ customUiTheme: JSON.stringify(uiTheme) });
}
/**
* 重置为默认 UI 主题并保存。
*/
async function resetCustomUiTheme() {
await saveCustomUiTheme(defaultUiTheme);
}
/**
* 设置激活的终端主题
* @param themeId 主题 ID
*/
async function setActiveTerminalTheme(themeId: string | null) {
await updateAppearanceSettings({ activeTerminalThemeId: themeId ?? undefined });
}
/**
* 设置终端字体
* @param fontFamily 字体列表字符串
*/
async function setTerminalFontFamily(fontFamily: string) {
await updateAppearanceSettings({ terminalFontFamily: fontFamily });
}
// --- 终端主题列表管理 Actions ---
/**
* 重新加载终端主题列表
*/
async function reloadTerminalThemes() {
try {
const response = await axios.get<TerminalTheme[]>('/api/v1/terminal-themes');
availableTerminalThemes.value = response.data;
} catch (err: any) {
console.error('重新加载终端主题列表失败:', err);
// 可以选择抛出错误或显示通知
}
}
/**
* 创建新的终端主题
* @param name 主题名称
* @param themeData 主题数据 (ITheme)
*/
async function createTerminalTheme(name: string, themeData: ITheme) {
try {
await axios.post('/api/v1/terminal-themes', { name, themeData });
await reloadTerminalThemes(); // 重新加载列表
} catch (err: any) {
console.error('创建终端主题失败:', err);
throw new Error(err.response?.data?.message || err.message || '创建终端主题失败');
}
}
/**
* 更新终端主题
* @param id 主题 ID
* @param name 新名称
* @param themeData 新主题数据
*/
async function updateTerminalTheme(id: string, name: string, themeData: ITheme) {
try {
await axios.put(`/api/v1/terminal-themes/${id}`, { name, themeData });
await reloadTerminalThemes(); // 重新加载列表
} catch (err: any) {
console.error('更新终端主题失败:', err);
throw new Error(err.response?.data?.message || err.message || '更新终端主题失败');
}
}
/**
* 删除终端主题
* @param id 主题 ID
*/
async function deleteTerminalTheme(id: string) {
try {
await axios.delete(`/api/v1/terminal-themes/${id}`);
// 如果删除的是当前激活的主题,则切换回默认
if (activeTerminalThemeId.value === id) {
await setActiveTerminalTheme(null); // 或者设置为默认主题的 ID
}
await reloadTerminalThemes(); // 重新加载列表
} catch (err: any) {
console.error('删除终端主题失败:', err);
throw new Error(err.response?.data?.message || err.message || '删除终端主题失败');
}
}
/**
* 导入终端主题文件
* @param file File 对象
* @param name 可选,如果提供则覆盖文件名作为主题名
*/
async function importTerminalTheme(file: File, name?: string) {
const formData = new FormData();
formData.append('themeFile', file);
if (name) {
formData.append('name', name);
}
try {
await axios.post('/api/v1/terminal-themes/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
await reloadTerminalThemes();
} catch (err: any) {
console.error('导入终端主题失败:', err);
throw new Error(err.response?.data?.message || err.message || '导入终端主题失败');
}
}
/**
* 导出终端主题文件
* @param id 主题 ID
*/
async function exportTerminalTheme(id: string) {
try {
const response = await axios.get(`/api/v1/terminal-themes/${id}/export`, {
responseType: 'blob' // 重要:接收二进制数据
});
// 从响应头获取文件名
const contentDisposition = response.headers['content-disposition'];
let filename = `terminal_theme_${id}.json`; // 默认文件名
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1) {
filename = filenameMatch[1];
}
}
// 创建下载链接并触发下载
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (err: any) {
console.error('导出终端主题失败:', err);
throw new Error(err.response?.data?.message || err.message || '导出终端主题失败');
}
}
// --- 背景图片 Actions ---
/**
* 上传页面背景图片
* @param file File 对象
*/
async function uploadPageBackground(file: File): Promise<string> {
const formData = new FormData();
formData.append('pageBackgroundFile', file);
try {
const response = await axios.post<{ filePath: string }>('/api/v1/appearance/background/page', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
// 更新本地状态 (虽然 updateAppearanceSettings 也会做,但这里立即反映)
appearanceSettings.value.pageBackgroundImage = response.data.filePath;
applyPageBackground(); // 应用新背景
return response.data.filePath;
} catch (err: any) {
console.error('上传页面背景失败:', err);
throw new Error(err.response?.data?.message || err.message || '上传页面背景失败');
}
}
/**
* 上传终端背景图片
* @param file File 对象
*/
async function uploadTerminalBackground(file: File): Promise<string> {
const formData = new FormData();
formData.append('terminalBackgroundFile', file);
try {
const response = await axios.post<{ filePath: string }>('/api/v1/appearance/background/terminal', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
appearanceSettings.value.terminalBackgroundImage = response.data.filePath;
// 终端背景的应用由 Terminal 组件处理
return response.data.filePath;
} catch (err: any) {
console.error('上传终端背景失败:', err);
throw new Error(err.response?.data?.message || err.message || '上传终端背景失败');
}
}
/**
* 设置页面背景透明度
* @param opacity 0-1 之间的数字
*/
async function setPageBackgroundOpacity(opacity: number) {
await updateAppearanceSettings({ pageBackgroundOpacity: opacity });
}
/**
* 设置终端背景透明度
* @param opacity 0-1 之间的数字
*/
async function setTerminalBackgroundOpacity(opacity: number) {
await updateAppearanceSettings({ terminalBackgroundOpacity: opacity });
}
/**
* 移除页面背景
*/
async function removePageBackground() {
await updateAppearanceSettings({ pageBackgroundImage: '' }); // 设置为空字符串或其他表示移除的值
}
/**
* 移除终端背景
*/
async function removeTerminalBackground() {
await updateAppearanceSettings({ terminalBackgroundImage: '' });
}
// --- Helper Functions ---
/**
* 将 UI 主题 (CSS 变量) 应用到文档根元素。
* @param theme 要应用的 UI 主题对象。
*/
function applyUiTheme(theme: Record<string, string>) {
const root = document.documentElement;
// 先移除可能存在的旧变量(可选,但更干净)
// Object.keys(defaultUiTheme).forEach(key => root.style.removeProperty(key));
// 应用新变量
for (const [key, value] of Object.entries(theme)) {
root.style.setProperty(key, value);
}
console.log('[AppearanceStore] UI 主题已应用:', theme);
}
/**
* 应用页面背景设置到 body 元素
*/
function applyPageBackground() {
const body = document.body;
if (pageBackgroundImage.value) {
// --- 修改开始 ---
// 使用环境变量获取后端基础 URL
const backendUrl = import.meta.env.VITE_API_BASE_URL || ''; // 提供一个默认空字符串以防万一
const imagePath = pageBackgroundImage.value;
console.log(`[AppearanceStore applyPageBackground] backendUrl: "${backendUrl}", imagePath: "${imagePath}"`); // 详细日志
const fullImageUrl = `${backendUrl}${imagePath}`;
console.log(`[AppearanceStore applyPageBackground] fullImageUrl: "${fullImageUrl}"`); // 打印完整 URL
// --- 修改结束 ---
// Use the full URL
// 先设置为空,强制更新
body.style.backgroundImage = 'none';
// 在下一个 tick 中设置图片,尝试解决时序问题
nextTick(() => {
body.style.backgroundImage = `url(${fullImageUrl})`;
body.style.backgroundSize = 'cover';
body.style.backgroundPosition = 'center';
body.style.backgroundRepeat = 'no-repeat';
});
// 可以考虑添加透明度处理,例如通过伪元素
} else {
body.style.backgroundImage = 'none';
}
// 注意:直接设置 body 透明度会影响所有子元素,通常不建议。
// 如果需要背景透明效果,通常结合伪元素或额外 div 实现。
// 这里暂时不直接应用 pageBackgroundOpacity 到 body。
console.log('[AppearanceStore] 页面背景已应用:', pageBackgroundImage.value);
}
// --- Watchers ---
// 监听 UI 主题变化并应用
watch(currentUiTheme, (newTheme) => {
applyUiTheme(newTheme);
}, { deep: true });
// 监听页面背景变化并应用
watch([pageBackgroundImage, pageBackgroundOpacity], () => {
applyPageBackground();
});
return {
isLoading,
error,
// State refs (原始数据)
appearanceSettings,
availableTerminalThemes,
// Computed Getters
currentUiTheme,
activeTerminalThemeId,
currentTerminalTheme,
currentTerminalFontFamily,
pageBackgroundImage,
pageBackgroundOpacity,
terminalBackgroundImage,
terminalBackgroundOpacity,
// Actions
loadInitialAppearanceData,
updateAppearanceSettings,
saveCustomUiTheme,
resetCustomUiTheme,
setActiveTerminalTheme,
setTerminalFontFamily,
reloadTerminalThemes,
createTerminalTheme,
updateTerminalTheme,
deleteTerminalTheme,
importTerminalTheme,
exportTerminalTheme,
uploadPageBackground,
uploadTerminalBackground,
setPageBackgroundOpacity,
setTerminalBackgroundOpacity,
removePageBackground,
removeTerminalBackground,
// Visibility control
isStyleCustomizerVisible,
toggleStyleCustomizer,
};
});
@@ -0,0 +1,46 @@
import type { ITheme } from 'xterm';
// 默认 xterm 主题
// (与 backend/src/config/default-themes.ts 中的定义保持一致)
export const defaultXtermTheme: ITheme = {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
selectionBackground: '#264f78', // 使用 selectionBackground
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5'
};
// 默认 UI 主题 (CSS 变量)
// (与 backend/src/config/default-themes.ts 中的定义保持一致)
export const defaultUiTheme: Record<string, string> = {
'--app-bg-color': '#ffffff',
'--text-color': '#333333',
'--text-color-secondary': '#666666',
'--border-color': '#cccccc',
'--link-color': '#333',
'--link-hover-color': '#0056b3',
'--link-active-color': '#007bff',
'--header-bg-color': '#f0f0f0',
'--footer-bg-color': '#f0f0f0',
'--button-bg-color': '#007bff',
'--button-text-color': '#ffffff',
'--button-hover-bg-color': '#0056b3',
'--font-family-sans-serif': 'sans-serif',
'--base-padding': '1rem',
'--base-margin': '0.5rem',
};
+95 -197
View File
@@ -1,305 +1,203 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import { ref, computed, watch } from 'vue'; // Import computed and watch
import { ref, computed } from 'vue'; // 移除 watch
import i18n, { setLocale, defaultLng } from '../i18n'; // Import i18n instance and setLocale
import type { ITheme } from 'xterm'; // 导入 xterm 主题类型
// 移除 ITheme 和默认主题定义,这些移到 appearance.store.ts
// Define the type for settings state explicitly
// 定义通用设置状态类型
interface SettingsState {
language: 'en' | 'zh';
ipWhitelist: string;
maxLoginAttempts: string;
loginBanDuration: string;
showPopupFileEditor: string; // 弹窗编辑器设置
shareFileEditorTabs?: string; // 共享编辑器标签页设置 ('true'/'false')
customUiTheme?: string; // UI 主题 (CSS 变量 JSON 字符串)
customXtermTheme?: string; // xterm 主题 (JSON 字符串)
// Add other settings keys here as needed
[key: string]: string | undefined; // Allow other string settings, make value optional
language?: 'en' | 'zh'; // 语言现在是可选的,因为可能在 appearance store 中处理
ipWhitelist?: string;
maxLoginAttempts?: string;
loginBanDuration?: string;
showPopupFileEditor?: string; // 'true' or 'false'
shareFileEditorTabs?: string; // 'true' or 'false'
ipWhitelistEnabled?: string; // 添加 IP 白名单启用状态 'true' or 'false'
// Add other general settings keys here as needed
[key: string]: string | undefined; // Allow other string settings
}
// 默认 UI 主题 (CSS 变量)
const defaultUiTheme: Record<string, string> = {
'--app-bg-color': '#ffffff',
'--text-color': '#333333',
'--text-color-secondary': '#666666',
'--border-color': '#cccccc',
'--link-color': '#333',
'--link-hover-color': '#0056b3',
'--link-active-color': '#007bff',
'--header-bg-color': '#f0f0f0',
'--footer-bg-color': '#f0f0f0',
'--button-bg-color': '#007bff',
'--button-text-color': '#ffffff',
'--button-hover-bg-color': '#0056b3',
'--font-family-sans-serif': 'sans-serif',
'--base-padding': '1rem',
'--base-margin': '0.5rem',
};
// 默认 xterm 主题
const defaultXtermTheme: ITheme = {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
selectionBackground: '#264f78', // 使用 selectionBackground 而不是 selection
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5'
};
export const useSettingsStore = defineStore('settings', () => {
// --- State ---
const settings = ref<Partial<SettingsState>>({}); // Use Partial initially
const settings = ref<Partial<SettingsState>>({}); // 通用设置状态
const isLoading = ref(false);
const error = ref<string | null>(null);
const isStyleCustomizerVisible = ref(false); // 控制样式编辑器可见性
const currentUiTheme = ref<Record<string, string>>({ ...defaultUiTheme }); // 当前应用的 UI 主题
const currentXtermTheme = ref<ITheme>({ ...defaultXtermTheme }); // 当前应用的 xterm 主题
// 移除外观相关状态: isStyleCustomizerVisible, currentUiTheme, currentXtermTheme
// --- Actions ---
/**
* Fetches all settings from the backend and updates the store state.
* Fetches general settings from the backend and updates the store state.
* Also sets the i18n locale based on the fetched language setting.
* Should be called early in the application lifecycle (e.g., main.ts).
*/
async function loadInitialSettings() {
console.log('[SettingsStore] Entering loadInitialSettings function...'); // <-- 添加更早的日志
isLoading.value = true;
error.value = null;
let fetchedLang: 'en' | 'zh' | undefined;
try {
console.log('[SettingsStore] Starting loadInitialSettings...'); // 添加日志
console.log('[SettingsStore] 加载通用设置...');
const response = await axios.get<Record<string, string>>('/api/v1/settings');
settings.value = response.data; // Store all fetched settings
console.log('[SettingsStore] Fetched settings raw:', JSON.stringify(response.data)); // 打印原始响应
console.log('[SettingsStore] Raw showPopupFileEditor from backend:', response.data.showPopupFileEditor);
settings.value = response.data; // Store fetched general settings
console.log('[SettingsStore] 通用设置已加载:', settings.value);
// --- 设置默认值 (如果后端未返回) ---
// 弹窗编辑器设置 (保持不变)
if (settings.value.showPopupFileEditor === undefined) {
console.log('[SettingsStore] showPopupFileEditor is undefined, setting default: true');
settings.value.showPopupFileEditor = 'true';
}
// 共享编辑器标签页设置 (保持不变)
if (settings.value.shareFileEditorTabs === undefined) {
console.log('[SettingsStore] Setting default for shareFileEditorTabs: true');
settings.value.shareFileEditorTabs = 'true';
}
if (settings.value.ipWhitelistEnabled === undefined) {
settings.value.ipWhitelistEnabled = 'false'; // 默认禁用 IP 白名单
}
if (settings.value.maxLoginAttempts === undefined) {
settings.value.maxLoginAttempts = '5'; // 默认 5 次
}
if (settings.value.loginBanDuration === undefined) {
settings.value.loginBanDuration = '300'; // 默认 300 秒
}
// --- 加载自定义主题 ---
loadAndApplyThemesFromSettings(); // 新增:加载并应用主题
// --- 语言设置 (保持不变) ---
// Determine and apply language
// --- 语言设置 ---
const langFromSettings = settings.value.language;
if (langFromSettings === 'en' || langFromSettings === 'zh') {
fetchedLang = langFromSettings;
} else {
// Fallback logic if setting is missing or invalid
const navigatorLang = navigator.language?.split('-')[0];
fetchedLang = navigatorLang === 'zh' ? 'zh' : defaultLng; // Use browser lang or default
console.warn(`[SettingsStore] Language setting not found or invalid ('${langFromSettings}'). Falling back to '${fetchedLang}'.`);
// Optionally save the fallback language back to the backend if desired
fetchedLang = navigatorLang === 'zh' ? 'zh' : defaultLng;
console.warn(`[SettingsStore] 语言设置无效 ('${langFromSettings}'), 回退到 '${fetchedLang}'.`);
// Optionally save the fallback language back
// await updateSetting('language', fetchedLang);
}
// Ensure fetchedLang is valid before calling setLocale (保持不变)
if (fetchedLang) {
console.log(`[SettingsStore] Determined language: ${fetchedLang}. Applying locale...`);
console.log(`[SettingsStore] 设置语言: ${fetchedLang}`);
setLocale(fetchedLang);
} else {
console.error('[SettingsStore] Could not determine a valid language to set.');
console.error('[SettingsStore] 无法确定有效语言。');
setLocale(defaultLng);
}
} catch (err: any) {
console.error('Failed to load initial settings:', err);
error.value = err.response?.data?.message || err.message || 'Failed to load settings';
// Apply default language on error
setLocale(defaultLng);
console.error('加载通用设置失败:', err);
error.value = err.response?.data?.message || err.message || '加载设置失败';
setLocale(defaultLng); // 出错时使用默认语言
} finally {
isLoading.value = false;
}
}
/**
* 从 settings ref 加载主题设置,解析并应用它们。
*/
function loadAndApplyThemesFromSettings() {
// 加载 UI 主题
try {
if (settings.value.customUiTheme) {
const parsedUiTheme = JSON.parse(settings.value.customUiTheme);
// 合并默认值,确保所有变量都存在
currentUiTheme.value = { ...defaultUiTheme, ...parsedUiTheme };
} else {
currentUiTheme.value = { ...defaultUiTheme }; // 使用默认值
}
} catch (e) {
console.error('[SettingsStore] Failed to parse custom UI theme, using default:', e);
currentUiTheme.value = { ...defaultUiTheme };
}
// 加载 xterm 主题
try {
if (settings.value.customXtermTheme) {
const parsedXtermTheme = JSON.parse(settings.value.customXtermTheme);
// 合并默认值
currentXtermTheme.value = { ...defaultXtermTheme, ...parsedXtermTheme };
} else {
currentXtermTheme.value = { ...defaultXtermTheme }; // 使用默认值
}
} catch (e) {
console.error('[SettingsStore] Failed to parse custom xterm theme, using default:', e);
currentXtermTheme.value = { ...defaultXtermTheme };
}
// 应用加载的主题
applyUiTheme(currentUiTheme.value);
// xterm 主题的应用将在 Terminal 组件内部通过 watch 监听 currentXtermTheme 实现
}
// 移除外观相关函数: loadAndApplyThemesFromSettings, applyUiTheme, saveCustomThemes, resetCustomThemes, toggleStyleCustomizer
/**
* 将 UI 主题 (CSS 变量) 应用到文档根元素。
* @param theme 要应用的 UI 主题对象。
*/
function applyUiTheme(theme: Record<string, string>) {
const root = document.documentElement;
for (const [key, value] of Object.entries(theme)) {
root.style.setProperty(key, value);
}
console.log('[SettingsStore] Applied UI theme:', theme);
}
// 监听 currentUiTheme 的变化并自动应用
watch(currentUiTheme, (newTheme) => {
applyUiTheme(newTheme);
}, { deep: true }); // 使用 deep watch 监听对象内部变化
/**
* Updates a single setting value both locally and on the backend.
* Updates a single general setting value both locally and on the backend.
* @param key The setting key to update.
* @param value The new value for the setting.
*/
async function updateSetting(key: keyof SettingsState, value: string) {
// 移除外观相关的键检查
const allowedKeys: Array<keyof SettingsState> = [
'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration',
'showPopupFileEditor', 'shareFileEditorTabs', 'ipWhitelistEnabled'
];
if (!allowedKeys.includes(key)) {
console.error(`[SettingsStore] 尝试更新不允许的设置键: ${key}`);
throw new Error(`不允许更新设置项 '${key}'`);
}
try {
// 注意:后端 controller 现在会过滤,但前端也做一层检查更好
await axios.put('/api/v1/settings', { [key]: value });
// Update store state *after* successful API call
settings.value = { ...settings.value, [key]: value };
// 如果更新的是主题设置,需要重新解析和应用
if (key === 'customUiTheme' || key === 'customXtermTheme') {
loadAndApplyThemesFromSettings();
}
// If updating language, also update i18n
if (key === 'language' && (value === 'en' || value === 'zh')) {
setLocale(value);
}
} catch (err: any) {
console.error(`Failed to update setting '${key}':`, err);
throw new Error(err.response?.data?.message || err.message || `Failed to update setting '${key}'`);
console.error(`更新设置项 '${key}' 失败:`, err);
throw new Error(err.response?.data?.message || err.message || `更新设置项 '${key}' 失败`);
}
}
/**
* Updates multiple settings values both locally and on the backend.
* Updates multiple general settings values both locally and on the backend.
* @param updates An object containing key-value pairs of settings to update.
*/
async function updateMultipleSettings(updates: Partial<SettingsState>) {
// 移除外观相关的键检查
const allowedKeys: Array<keyof SettingsState> = [
'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration',
'showPopupFileEditor', 'shareFileEditorTabs', 'ipWhitelistEnabled'
];
const filteredUpdates: Partial<SettingsState> = {};
let languageUpdate: 'en' | 'zh' | undefined = undefined;
for (const key in updates) {
if (allowedKeys.includes(key as keyof SettingsState)) {
filteredUpdates[key as keyof SettingsState] = updates[key];
if (key === 'language' && (updates[key] === 'en' || updates[key] === 'zh')) {
languageUpdate = updates[key] as 'en' | 'zh';
}
} else {
console.warn(`[SettingsStore] 尝试批量更新不允许的设置键: ${key}`);
}
}
if (Object.keys(filteredUpdates).length === 0) {
console.log('[SettingsStore] 没有有效的通用设置需要更新。');
return; // 没有有效设置需要更新
}
try {
await axios.put('/api/v1/settings', updates);
// 注意:后端 controller 现在会过滤,但前端也做一层检查更好
await axios.put('/api/v1/settings', filteredUpdates);
// Update store state *after* successful API call
settings.value = { ...settings.value, ...updates };
// 如果更新包含主题设置,需要重新解析和应用
if (updates.customUiTheme !== undefined || updates.customXtermTheme !== undefined) {
loadAndApplyThemesFromSettings();
}
settings.value = { ...settings.value, ...filteredUpdates };
// If language is updated, apply it
if (updates.language && (updates.language === 'en' || updates.language === 'zh')) {
setLocale(updates.language);
if (languageUpdate) {
setLocale(languageUpdate);
}
} catch (err: any) {
console.error('Failed to update multiple settings:', err);
throw new Error(err.response?.data?.message || err.message || 'Failed to update settings');
console.error('批量更新设置失败:', err);
throw new Error(err.response?.data?.message || err.message || '批量更新设置失败');
}
}
/**
* 保存当前编辑器中的自定义主题到后端。
* @param uiTheme UI 主题对象
* @param xtermTheme xterm 主题对象
*/
async function saveCustomThemes(uiTheme: Record<string, string>, xtermTheme: ITheme) {
const updates: Partial<SettingsState> = {
customUiTheme: JSON.stringify(uiTheme),
customXtermTheme: JSON.stringify(xtermTheme),
};
// 更新本地状态以立即反映(虽然 watch 也会触发应用,但这里更新 state 是必要的)
currentUiTheme.value = { ...uiTheme };
currentXtermTheme.value = { ...xtermTheme };
// 调用 updateMultipleSettings 保存到后端
await updateMultipleSettings(updates);
}
// 移除外观相关 actions: saveCustomThemes, resetCustomThemes, toggleStyleCustomizer
/**
* 重置为默认主题并保存。
*/
async function resetCustomThemes() {
await saveCustomThemes(defaultUiTheme, defaultXtermTheme);
}
/**
* 切换样式编辑器面板的可见性。
* @param visible 可选,强制设置可见性
*/
function toggleStyleCustomizer(visible?: boolean) {
isStyleCustomizerVisible.value = visible === undefined ? !isStyleCustomizerVisible.value : visible;
}
// --- Getters --- (保持不变)
// --- Getters ---
const language = computed(() => settings.value.language || defaultLng);
// Getter for the popup editor setting, returning boolean (保持不变)
// Getter for the popup editor setting, returning boolean
const showPopupFileEditorBoolean = computed(() => {
return settings.value.showPopupFileEditor !== 'false';
});
// Getter for sharing setting, returning boolean (保持不变)
// Getter for sharing setting, returning boolean
const shareFileEditorTabsBoolean = computed(() => {
return settings.value.shareFileEditorTabs !== 'false';
});
// Getter for IP Whitelist enabled status
const ipWhitelistEnabled = computed(() => settings.value.ipWhitelistEnabled === 'true');
return {
settings, // 原始设置对象 (可能包含字符串化的主题)
settings, // 只包含通用设置
isLoading,
error,
language,
showPopupFileEditorBoolean,
shareFileEditorTabsBoolean,
isStyleCustomizerVisible, // 暴露编辑器可见状态
currentUiTheme, // 暴露当前应用的 UI 主题对象
currentXtermTheme, // 暴露当前应用的 xterm 主题对象
ipWhitelistEnabled, // 暴露 IP 白名单启用状态
// 移除外观相关的 getters 和 actions
loadInitialSettings,
updateSetting,
updateMultipleSettings,
saveCustomThemes, // 暴露保存主题 action
resetCustomThemes, // 暴露重置主题 action
toggleStyleCustomizer, // 暴露切换编辑器 action
};
});
+17 -4
View File
@@ -143,10 +143,17 @@
</form>
</div>
<!-- 外观设置 -->
<div class="settings-section">
<h2>{{ $t('settings.appearance.title') }}</h2>
<p>{{ $t('settings.appearance.description') }}</p>
<button @click="openStyleCustomizer">{{ t('settings.appearance.customizeButton') }}</button>
</div>
<hr>
<!-- IP 黑名单管理 -->
<div class="settings-section">
<div class="settings-section">
<h2>IP 黑名单管理</h2>
<p>配置登录失败次数限制和自动封禁时长本地地址 (127.0.0.1, ::1) 不会被封禁</p>
@@ -208,17 +215,19 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed, reactive, watch, toRefs } from 'vue';
import { ref, onMounted, computed, reactive, watch } from 'vue'; // 移除 toRefs
import { useAuthStore } from '../stores/auth.store';
import { useSettingsStore } from '../stores/settings.store';
import { useAppearanceStore } from '../stores/appearance.store'; // 导入外观 store
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
import { storeToRefs } from 'pinia';
// setLocale is handled by the store now
import axios from 'axios';
import { startRegistration } from '@simplewebauthn/browser';
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const appearanceStore = useAppearanceStore(); // 实例化外观 store
const { t } = useI18n();
// --- Reactive state from store ---
@@ -315,6 +324,10 @@ const handleUpdateShareTabsSetting = async () => {
}
};
// --- 外观设置 ---
const openStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(true);
};
// --- Passkey state & methods --- (Keep as is)
const passkeyName = ref('');