diff --git a/packages/frontend/src/composables/settings/useAboutSection.ts b/packages/frontend/src/composables/settings/useAboutSection.ts new file mode 100644 index 0000000..07393f3 --- /dev/null +++ b/packages/frontend/src/composables/settings/useAboutSection.ts @@ -0,0 +1,78 @@ +import { ref, computed, onMounted } from 'vue'; +import axios from 'axios'; +import pkg from '../../../package.json'; // 路径相对于当前文件 +import { useI18n } from 'vue-i18n'; + +export function useAboutSection() { + const { t } = useI18n(); + const appVersion = ref(pkg.version); + + // --- Version Check State --- + const latestVersion = ref(null); + const isCheckingVersion = ref(false); + const versionCheckError = ref(null); + + const isUpdateAvailable = computed(() => { + // 简单的字符串比较,假设 tag 格式为 vX.Y.Z 或 X.Y.Z + // 后端返回的 tag_name 可能包含 'v' 前缀,也可能不包含 + // appVersion.value 通常不包含 'v' + if (!latestVersion.value) return false; + + const cleanLatestVersion = latestVersion.value.startsWith('v') + ? latestVersion.value.substring(1) + : latestVersion.value; + const cleanAppVersion = appVersion.value.startsWith('v') + ? appVersion.value.substring(1) + : appVersion.value; + + // 进行版本比较,更健壮的比较可能需要拆分版本号进行数字比较 + // 此处简单比较字符串,对于 "1.0.10" > "1.0.9" 是有效的 + // 但对于 "1.0.9" > "1.0.10" 可能会出错,如果需要更精确,可以引入 semver 库或手动比较 + return cleanLatestVersion !== cleanAppVersion && cleanLatestVersion > cleanAppVersion; + }); + + + const checkLatestVersion = async () => { + isCheckingVersion.value = true; + versionCheckError.value = null; + latestVersion.value = null; // Reset before check + try { + const response = await axios.get('https://api.github.com/repos/Heavrnl/nexus-terminal/releases/latest', { + // 移除 headers 以尝试解决潜在的CORS或请求问题,GitHub API 通常不需要特定 headers 进行公共读取 + }); + if (response.data && response.data.tag_name) { + latestVersion.value = response.data.tag_name; + } else { + throw new Error('Invalid API response format'); + } + } catch (error: any) { + console.error('检查最新版本失败:', error); + if (axios.isAxiosError(error)) { + if (error.response?.status === 404) { + versionCheckError.value = t('settings.about.error.noReleases', '没有找到发布版本。'); + } else if (error.response?.status === 403) { + versionCheckError.value = t('settings.about.error.rateLimit', 'API 访问频率受限,请稍后再试。'); + } else { + versionCheckError.value = t('settings.about.error.checkFailed', '检查更新失败,请检查网络连接或稍后再试。'); + } + } else { + versionCheckError.value = t('settings.about.error.checkFailed', '检查更新失败,请检查网络连接或稍后再试。'); + } + } finally { + isCheckingVersion.value = false; + } + }; + + onMounted(() => { + checkLatestVersion(); + }); + + return { + appVersion, + latestVersion, + isCheckingVersion, + versionCheckError, + isUpdateAvailable, + checkLatestVersion, // Expose if manual refresh is needed + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useAppearanceSettings.ts b/packages/frontend/src/composables/settings/useAppearanceSettings.ts new file mode 100644 index 0000000..8992104 --- /dev/null +++ b/packages/frontend/src/composables/settings/useAppearanceSettings.ts @@ -0,0 +1,18 @@ +import { useAppearanceStore } from '../../stores/appearance.store'; +// import { useI18n } from 'vue-i18n'; // t function might be needed if there were messages + +export function useAppearanceSettings() { + const appearanceStore = useAppearanceStore(); + // const { t } = useI18n(); // Not strictly needed for just opening a modal yet + + const openStyleCustomizer = () => { + appearanceStore.toggleStyleCustomizer(true); + }; + + // No specific loading, message, or success states are typically needed for just opening a UI element. + // These would be managed within the StyleCustomizer component itself or its own composable if it gets complex. + + return { + openStyleCustomizer, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useCaptchaSettings.ts b/packages/frontend/src/composables/settings/useCaptchaSettings.ts new file mode 100644 index 0000000..08c170a --- /dev/null +++ b/packages/frontend/src/composables/settings/useCaptchaSettings.ts @@ -0,0 +1,171 @@ +import { ref, reactive, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { storeToRefs } from 'pinia'; +import { useSettingsStore } from '../../stores/settings.store'; +import apiClient from '../../utils/apiClient'; + +// Define necessary types locally if not shared, or import from a shared types file +export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'none'; + +export interface UpdateCaptchaSettingsDto { + enabled?: boolean; + provider?: CaptchaProvider; + hcaptchaSiteKey?: string; + hcaptchaSecretKey?: string; + recaptchaSiteKey?: string; + recaptchaSecretKey?: string; +} + +export function useCaptchaSettings() { + const settingsStore = useSettingsStore(); + const { t } = useI18n(); + const { captchaSettings } = storeToRefs(settingsStore); + + const captchaForm = reactive({ + enabled: false, + provider: 'none', + hcaptchaSiteKey: '', + hcaptchaSecretKey: '', + recaptchaSiteKey: '', + recaptchaSecretKey: '', + }); + + const captchaLoading = ref(false); + const captchaMessage = ref(''); + const captchaSuccess = ref(false); + + watch(captchaSettings, (newCaptchaSettings) => { + if (newCaptchaSettings) { + captchaForm.enabled = newCaptchaSettings.enabled; + captchaForm.provider = newCaptchaSettings.provider; + captchaForm.hcaptchaSiteKey = newCaptchaSettings.hcaptchaSiteKey || ''; + // Secret keys are not pre-filled from store for security; form will only send if user inputs a new one + captchaForm.hcaptchaSecretKey = ''; + captchaForm.recaptchaSiteKey = newCaptchaSettings.recaptchaSiteKey || ''; + captchaForm.recaptchaSecretKey = ''; + } else { + // Reset form if settings are null (e.g., on error or initial load without data) + captchaForm.enabled = false; + captchaForm.provider = 'none'; + captchaForm.hcaptchaSiteKey = ''; + captchaForm.hcaptchaSecretKey = ''; + captchaForm.recaptchaSiteKey = ''; + captchaForm.recaptchaSecretKey = ''; + } + }, { immediate: true, deep: true }); // deep: true might be useful if captchaSettings structure is complex + + const handleUpdateCaptchaSettings = async () => { + captchaLoading.value = true; + captchaMessage.value = ''; + captchaSuccess.value = false; + try { + let needsVerification = false; + let providerForVerification: CaptchaProvider | null = null; + let siteKeyForVerification: string | undefined = undefined; + let secretKeyForVerification: string | undefined = undefined; + + // Step 1: Determine if verification is needed + if (captchaForm.enabled && captchaForm.provider && captchaForm.provider !== 'none') { + const originalSettings = captchaSettings.value; // Persisted settings from store + + if (captchaForm.provider === 'hcaptcha') { + const originalSiteKeyValue = originalSettings?.hcaptchaSiteKey || ''; + const currentSiteKeyValue = captchaForm.hcaptchaSiteKey || ''; + const currentSecretKeyValue = captchaForm.hcaptchaSecretKey || ''; + + if (currentSiteKeyValue !== originalSiteKeyValue) { + if (!currentSiteKeyValue || !currentSecretKeyValue) { + throw new Error(t('settings.captcha.error.hcaptchaKeysRequired')); + } + needsVerification = true; + providerForVerification = 'hcaptcha'; + siteKeyForVerification = currentSiteKeyValue; + secretKeyForVerification = currentSecretKeyValue; + } else if (currentSecretKeyValue) { + if (!currentSiteKeyValue) { + throw new Error(t('settings.captcha.error.hcaptchaKeysRequired')); + } + needsVerification = true; + providerForVerification = 'hcaptcha'; + siteKeyForVerification = currentSiteKeyValue; + secretKeyForVerification = currentSecretKeyValue; + } + } else if (captchaForm.provider === 'recaptcha') { + const originalSiteKeyValue = originalSettings?.recaptchaSiteKey || ''; + const currentSiteKeyValue = captchaForm.recaptchaSiteKey || ''; + const currentSecretKeyValue = captchaForm.recaptchaSecretKey || ''; + + if (currentSiteKeyValue !== originalSiteKeyValue) { + if (!currentSiteKeyValue || !currentSecretKeyValue) { + throw new Error(t('settings.captcha.error.recaptchaKeysRequired')); + } + needsVerification = true; + providerForVerification = 'recaptcha'; + siteKeyForVerification = currentSiteKeyValue; + secretKeyForVerification = currentSecretKeyValue; + } else if (currentSecretKeyValue) { + if (!currentSiteKeyValue) { + throw new Error(t('settings.captcha.error.recaptchaKeysRequired')); + } + needsVerification = true; + providerForVerification = 'recaptcha'; + siteKeyForVerification = currentSiteKeyValue; + secretKeyForVerification = currentSecretKeyValue; + } + } + } + + // Step 2: Perform verification if needed + if (needsVerification && providerForVerification && siteKeyForVerification && secretKeyForVerification) { + try { + await apiClient.post('/settings/captcha/verify', { + provider: providerForVerification, + siteKey: siteKeyForVerification, + secretKey: secretKeyForVerification, + }); + } catch (verifyError: any) { + console.error('CAPTCHA verification failed:', verifyError); + throw new Error(verifyError.response?.data?.message || verifyError.message || t('settings.captcha.error.verificationFailed')); + } + } + + // Step 3: Prepare DTO for saving + const dtoToSave: UpdateCaptchaSettingsDto = { + enabled: captchaForm.enabled, + provider: captchaForm.provider, + hcaptchaSiteKey: captchaForm.hcaptchaSiteKey || undefined, + recaptchaSiteKey: captchaForm.recaptchaSiteKey || undefined, + hcaptchaSecretKey: captchaForm.hcaptchaSecretKey || undefined, + recaptchaSecretKey: captchaForm.recaptchaSecretKey || undefined, + }; + + // Step 4: Call save operation + await settingsStore.updateCaptchaSettings(dtoToSave); + captchaMessage.value = t('settings.captcha.success.saved'); + captchaSuccess.value = true; + // Clear secret key fields from the form after successful save + captchaForm.hcaptchaSecretKey = ''; + captchaForm.recaptchaSecretKey = ''; + + } catch (error: any) { + captchaMessage.value = error.message || t('settings.captcha.error.saveFailed'); + captchaSuccess.value = false; + } finally { + captchaLoading.value = false; + } + }; + + // Load initial CAPTCHA settings when the composable is used + // settingsStore.loadCaptchaSettings(); // This is called in SettingsView onMounted, might be redundant here unless SettingsView stops calling it. + // For now, assume SettingsView still handles initial load on its onMounted. + + return { + captchaForm, + captchaLoading, + captchaMessage, + captchaSuccess, + handleUpdateCaptchaSettings, + // Expose captchaSettings from store if needed by the template directly, though form uses captchaForm + // captchaSettingsStore: captchaSettings + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useChangePassword.ts b/packages/frontend/src/composables/settings/useChangePassword.ts new file mode 100644 index 0000000..19445e2 --- /dev/null +++ b/packages/frontend/src/composables/settings/useChangePassword.ts @@ -0,0 +1,56 @@ +import { ref } from 'vue'; +import { useAuthStore } from '../../stores/auth.store'; +import { useI18n } from 'vue-i18n'; + +export function useChangePassword() { + const authStore = useAuthStore(); + const { t } = useI18n(); + + const currentPassword = ref(''); + const newPassword = ref(''); + const confirmPassword = ref(''); + const changePasswordLoading = ref(false); + const changePasswordMessage = ref(''); + const changePasswordSuccess = ref(false); + + const handleChangePassword = async () => { + changePasswordMessage.value = ''; + changePasswordSuccess.value = false; + + if (newPassword.value !== confirmPassword.value) { + changePasswordMessage.value = t('settings.changePassword.error.passwordsDoNotMatch'); + return; + } + + if (!currentPassword.value || !newPassword.value) { + changePasswordMessage.value = t('settings.changePassword.error.fieldsRequired'); // 您可能需要添加此翻译 + return; + } + + changePasswordLoading.value = true; + try { + await authStore.changePassword(currentPassword.value, newPassword.value); + changePasswordMessage.value = t('settings.changePassword.success'); + changePasswordSuccess.value = true; + currentPassword.value = ''; + newPassword.value = ''; + confirmPassword.value = ''; + } catch (error: any) { + console.error('修改密码失败:', error); + changePasswordMessage.value = error.message || t('settings.changePassword.error.generic'); + changePasswordSuccess.value = false; + } finally { + changePasswordLoading.value = false; + } + }; + + return { + currentPassword, + newPassword, + confirmPassword, + changePasswordLoading, + changePasswordMessage, + changePasswordSuccess, + handleChangePassword, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useDataManagement.ts b/packages/frontend/src/composables/settings/useDataManagement.ts new file mode 100644 index 0000000..2646160 --- /dev/null +++ b/packages/frontend/src/composables/settings/useDataManagement.ts @@ -0,0 +1,75 @@ +import { ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import apiClient from '../../utils/apiClient'; +import { isAxiosError } from 'axios'; + +export function useDataManagement() { + const { t } = useI18n(); + + // --- Export Connections State & Method --- + const exportConnectionsLoading = ref(false); + const exportConnectionsMessage = ref(''); + const exportConnectionsSuccess = ref(false); + + const handleExportConnections = async () => { + exportConnectionsLoading.value = true; + exportConnectionsMessage.value = ''; + exportConnectionsSuccess.value = false; + try { + const response = await apiClient.get('/settings/export-connections', { + responseType: 'blob', + }); + + let filename = 'nexus_connections_export.zip'; + const disposition = response.headers['content-disposition']; + if (disposition && disposition.includes('attachment')) { + const filenameRegex = /filename[^;=\n]*=(?:(?:["'])(?.*?)\1|(?[^;\n]*))/; + const matches = filenameRegex.exec(disposition); + if (matches?.groups?.quoted || matches?.groups?.unquoted) { + filename = matches.groups.quoted || matches.groups.unquoted; + } + } + + const blob = new Blob([response.data], { type: response.headers['content-type'] || 'application/zip' }); + const url = window.URL.createObjectURL(blob); + 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); + + exportConnectionsMessage.value = t('settings.exportConnections.success', '导出成功。文件已开始下载。'); + exportConnectionsSuccess.value = true; + } catch (error: any) { + console.error('导出连接失败:', error); + let message = t('settings.exportConnections.error', '导出连接时发生错误。'); + if (isAxiosError(error) && error.response && error.response.data) { + if (error.response.data instanceof Blob && error.response.data.type === 'application/json') { + try { + const errorJson = JSON.parse(await error.response.data.text()); + message = errorJson.message || message; + } catch (e) { /* Blob not valid JSON */ } + } else if (typeof error.response.data === 'string' && error.response.data.length < 200) { // Avoid overly long string errors + message = error.response.data; + } else if (error.response.data && typeof error.response.data.message === 'string') { + message = error.response.data.message; + } + } else if (error.message) { + message = error.message; + } + exportConnectionsMessage.value = message; + exportConnectionsSuccess.value = false; + } finally { + exportConnectionsLoading.value = false; + } + }; + + return { + exportConnectionsLoading, + exportConnectionsMessage, + exportConnectionsSuccess, + handleExportConnections, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useExportConnections.ts b/packages/frontend/src/composables/settings/useExportConnections.ts new file mode 100644 index 0000000..2b7967a --- /dev/null +++ b/packages/frontend/src/composables/settings/useExportConnections.ts @@ -0,0 +1,74 @@ +import { ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import apiClient from '../../utils/apiClient'; +import { isAxiosError } from 'axios'; + +export function useExportConnections() { + const { t } = useI18n(); + + const exportConnectionsLoading = ref(false); + const exportConnectionsMessage = ref(''); + const exportConnectionsSuccess = ref(false); + + const handleExportConnections = async () => { + exportConnectionsLoading.value = true; + exportConnectionsMessage.value = ''; + exportConnectionsSuccess.value = false; + try { + const response = await apiClient.get('/settings/export-connections', { + responseType: 'blob', + }); + + let filename = 'nexus_connections_export.zip'; + const disposition = response.headers['content-disposition']; + if (disposition && disposition.includes('attachment')) { + const filenameRegex = /filename[^;=\n]*=(?:(['"])(.*?)\1|([^;\n]*))/; + const matches = filenameRegex.exec(disposition); + if (matches != null && (matches[2] || matches[3])) { + filename = matches[2] || matches[3]; + } + } + + const blob = new Blob([response.data], { type: response.headers['content-type'] || 'application/zip' }); + const url = window.URL.createObjectURL(blob); + 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); + + exportConnectionsMessage.value = t('settings.exportConnections.success', '导出成功。文件已开始下载。'); + exportConnectionsSuccess.value = true; + } catch (error: any) { + console.error('导出连接失败:', error); + let message = t('settings.exportConnections.error', '导出连接时发生错误。'); + if (isAxiosError(error) && error.response && error.response.data) { + if (error.response.data instanceof Blob && error.response.data.type === 'application/json') { + try { + const errorJson = JSON.parse(await error.response.data.text()); + message = errorJson.message || message; + } catch (e) { /* Blob not valid JSON */ } + } else if (typeof error.response.data === 'string') { + message = error.response.data; + } else if (error.response.data && typeof error.response.data.message === 'string') { + message = error.response.data.message; + } + } else if (error.message) { + message = error.message; + } + exportConnectionsMessage.value = message; + exportConnectionsSuccess.value = false; + } finally { + exportConnectionsLoading.value = false; + } + }; + + return { + exportConnectionsLoading, + exportConnectionsMessage, + exportConnectionsSuccess, + handleExportConnections, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useIpBlacklist.ts b/packages/frontend/src/composables/settings/useIpBlacklist.ts new file mode 100644 index 0000000..4c19002 --- /dev/null +++ b/packages/frontend/src/composables/settings/useIpBlacklist.ts @@ -0,0 +1,158 @@ +import { ref, reactive, watch, onMounted } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { storeToRefs } from 'pinia'; +import { useSettingsStore } from '../../stores/settings.store'; +import { useAuthStore } from '../../stores/auth.store'; + +export function useIpBlacklist() { + const settingsStore = useSettingsStore(); + const authStore = useAuthStore(); + const { t } = useI18n(); + const { settings, ipBlacklistEnabledBoolean } = storeToRefs(settingsStore); + + // --- IP Blacklist Enabled State & Method --- + const ipBlacklistEnabled = ref(true); // Local state for the switch + + watch(ipBlacklistEnabledBoolean, (newVal) => { + ipBlacklistEnabled.value = newVal; + }, { immediate: true }); + + const handleUpdateIpBlacklistEnabled = async () => { + const originalValue = ipBlacklistEnabled.value; + // Toggle local state immediately for UI feedback if it's directly bound to the switch + // If the switch v-model is ipBlacklistEnabled, this line is not strictly needed before API call, + // but helps if we want to manage the state change explicitly. + // ipBlacklistEnabled.value = !ipBlacklistEnabled.value; // This line might be redundant if v-model handles it + + try { + // The value to save is the new state of the switch + const valueToSave = ipBlacklistEnabled.value; // This should reflect the intended new state + await settingsStore.updateSetting('ipBlacklistEnabled', valueToSave ? 'true' : 'false'); + // Success: ipBlacklistEnabledBoolean will update via store watcher, syncing ipBlacklistEnabled.value + } catch (error: any) { + console.error('更新 IP 黑名单启用状态失败:', error); + ipBlacklistEnabled.value = originalValue; // Revert on failure + // Optionally, show an error message to the user + } + }; + + + // --- IP Blacklist Configuration Form State & Method --- + const blacklistSettingsForm = reactive({ + maxLoginAttempts: '5', + loginBanDuration: '300', + }); + const blacklistSettingsLoading = ref(false); + const blacklistSettingsMessage = ref(''); + const blacklistSettingsSuccess = ref(false); + + watch(settings, (newSettings) => { + blacklistSettingsForm.maxLoginAttempts = newSettings.maxLoginAttempts || '5'; + blacklistSettingsForm.loginBanDuration = newSettings.loginBanDuration || '300'; + }, { deep: true, immediate: true }); + + const handleUpdateBlacklistSettings = async () => { + blacklistSettingsLoading.value = true; + blacklistSettingsMessage.value = ''; + blacklistSettingsSuccess.value = false; + try { + const maxAttempts = parseInt(blacklistSettingsForm.maxLoginAttempts, 10); + const banDuration = parseInt(blacklistSettingsForm.loginBanDuration, 10); + if (isNaN(maxAttempts) || maxAttempts <= 0) { + throw new Error(t('settings.ipBlacklist.error.invalidMaxAttempts')); + } + if (isNaN(banDuration) || banDuration <= 0) { + throw new Error(t('settings.ipBlacklist.error.invalidBanDuration')); + } + await settingsStore.updateMultipleSettings({ + maxLoginAttempts: blacklistSettingsForm.maxLoginAttempts, + loginBanDuration: blacklistSettingsForm.loginBanDuration, + }); + blacklistSettingsMessage.value = t('settings.ipBlacklist.success.configUpdated'); + blacklistSettingsSuccess.value = true; + } catch (error: any) { + console.error('更新黑名单配置失败:', error); + blacklistSettingsMessage.value = error.message || t('settings.ipBlacklist.error.updateConfigFailed'); + blacklistSettingsSuccess.value = false; + } finally { + blacklistSettingsLoading.value = false; + } + }; + + // --- IP Blacklist Table State & Methods --- + const ipBlacklist = reactive({ + entries: [] as any[], + total: 0, + loading: false, + error: null as string | null, + currentPage: 1, + limit: 10, + }); + const blacklistToDeleteIp = ref(null); + const blacklistDeleteLoading = ref(false); + const blacklistDeleteError = ref(null); + + const fetchIpBlacklist = async (page = 1) => { + ipBlacklist.loading = true; + ipBlacklist.error = null; + const offset = (page - 1) * ipBlacklist.limit; + try { + const data = await authStore.fetchIpBlacklist(ipBlacklist.limit, offset); + ipBlacklist.entries = data.entries; + ipBlacklist.total = data.total; + ipBlacklist.currentPage = page; + } catch (error: any) { + ipBlacklist.error = error.message || t('settings.ipBlacklist.error.fetchFailed'); + } finally { + ipBlacklist.loading = false; + } + }; + + const handleDeleteIp = async (ip: string) => { + blacklistToDeleteIp.value = ip; + if (confirm(t('settings.ipBlacklist.confirmRemoveIp', { ip }))) { + blacklistDeleteLoading.value = true; + blacklistDeleteError.value = null; + try { + await authStore.deleteIpFromBlacklist(ip); + await fetchIpBlacklist(ipBlacklist.currentPage); // Refresh list + } catch (error: any) { + blacklistDeleteError.value = error.message || t('settings.ipBlacklist.error.deleteFailed'); + } finally { + blacklistDeleteLoading.value = false; + blacklistToDeleteIp.value = null; + } + } else { + blacklistToDeleteIp.value = null; + } + }; + + onMounted(() => { + if (ipBlacklistEnabled.value) { // Only fetch if enabled, or always fetch and let template hide + fetchIpBlacklist(); + } + }); + + watch(ipBlacklistEnabled, (newValue) => { + if (newValue && ipBlacklist.entries.length === 0 && !ipBlacklist.loading) { + fetchIpBlacklist(); + } + }); + + + return { + ipBlacklistEnabled, + handleUpdateIpBlacklistEnabled, + blacklistSettingsForm, + blacklistSettingsLoading, + blacklistSettingsMessage, + blacklistSettingsSuccess, + handleUpdateBlacklistSettings, + ipBlacklist, + blacklistToDeleteIp, + blacklistDeleteLoading, + blacklistDeleteError, + fetchIpBlacklist, // Expose if pagination is handled in the template + handleDeleteIp, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useIpWhitelist.ts b/packages/frontend/src/composables/settings/useIpWhitelist.ts new file mode 100644 index 0000000..6772ac6 --- /dev/null +++ b/packages/frontend/src/composables/settings/useIpWhitelist.ts @@ -0,0 +1,44 @@ +import { ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { storeToRefs } from 'pinia'; +import { useSettingsStore } from '../../stores/settings.store'; + +export function useIpWhitelist() { + const settingsStore = useSettingsStore(); + const { t } = useI18n(); + const { settings } = storeToRefs(settingsStore); + + const ipWhitelistInput = ref(''); + const ipWhitelistLoading = ref(false); + const ipWhitelistMessage = ref(''); + const ipWhitelistSuccess = ref(false); + + watch(() => settings.value.ipWhitelist, (newVal) => { + ipWhitelistInput.value = newVal || ''; + }, { immediate: true }); + + const handleUpdateIpWhitelist = async () => { + ipWhitelistLoading.value = true; + ipWhitelistMessage.value = ''; + ipWhitelistSuccess.value = false; + try { + await settingsStore.updateSetting('ipWhitelist', ipWhitelistInput.value.trim()); + ipWhitelistMessage.value = t('settings.ipWhitelist.success.saved'); + ipWhitelistSuccess.value = true; + } catch (error: any) { + console.error('更新 IP 白名单失败:', error); + ipWhitelistMessage.value = error.message || t('settings.ipWhitelist.error.saveFailed'); + ipWhitelistSuccess.value = false; + } finally { + ipWhitelistLoading.value = false; + } + }; + + return { + ipWhitelistInput, + ipWhitelistLoading, + ipWhitelistMessage, + ipWhitelistSuccess, + handleUpdateIpWhitelist, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/usePasskeyManagement.ts b/packages/frontend/src/composables/settings/usePasskeyManagement.ts new file mode 100644 index 0000000..aa8ac9b --- /dev/null +++ b/packages/frontend/src/composables/settings/usePasskeyManagement.ts @@ -0,0 +1,157 @@ +import { ref, reactive, computed } from 'vue'; +import { useAuthStore } from '../../stores/auth.store'; +import { useI18n } from 'vue-i18n'; +import { startRegistration } from '@simplewebauthn/browser'; +import { storeToRefs } from 'pinia'; + +export function usePasskeyManagement() { + const authStore = useAuthStore(); + const { t } = useI18n(); + const { user, passkeys, passkeysLoading } = storeToRefs(authStore); + + // --- Passkey State --- + const passkeyRegistrationLoading = ref(false); // Renamed for clarity + const passkeyMessage = ref(''); + const passkeySuccess = ref(false); + const passkeyDeleteLoadingStates = reactive>({}); + const passkeyDeleteError = ref(null); + + // State for editing passkey name + const editingPasskeyId = ref(null); + const editingPasskeyName = ref(''); + const passkeyEditLoadingStates = reactive>({}); + + const handleRegisterNewPasskey = async () => { + passkeyRegistrationLoading.value = true; + passkeyMessage.value = ''; + passkeySuccess.value = false; + + const username = user.value?.username; + if (!username) { + passkeyMessage.value = t('settings.passkey.error.userNotLoggedIn'); + passkeyRegistrationLoading.value = false; + return; + } + + try { + const registrationOptions = await authStore.getPasskeyRegistrationOptions(username); + const registrationResult = await startRegistration(registrationOptions); + await authStore.registerPasskey(username, registrationResult); + + passkeyMessage.value = t('settings.passkey.success.registered'); + passkeySuccess.value = true; + await authStore.fetchPasskeys(); + } catch (error: any) { + console.error('Passkey 注册失败:', error); + if (error.name === 'InvalidStateError' || error.message.includes('cancelled') || error.message.includes('excludeCredentials')) { + passkeyMessage.value = t('settings.passkey.error.registrationCancelledOrExists'); // 您可能需要添加或修改此翻译 + } else { + passkeyMessage.value = error.response?.data?.message || error.message || t('settings.passkey.error.registrationFailed'); + } + passkeySuccess.value = false; + } finally { + passkeyRegistrationLoading.value = false; + } + }; + + const startEditPasskeyName = (credentialID: string, currentName: string) => { + editingPasskeyId.value = credentialID; + editingPasskeyName.value = currentName || ''; // Ensure it's a string + passkeyMessage.value = ''; + passkeySuccess.value = false; + }; + + const cancelEditPasskeyName = () => { + editingPasskeyId.value = null; + editingPasskeyName.value = ''; + }; + + const savePasskeyName = async (credentialID: string) => { + if (!editingPasskeyName.value.trim()) { + passkeyMessage.value = t('settings.passkey.error.nameRequired', 'Passkey 名称不能为空。'); + passkeySuccess.value = false; + return; + } + passkeyEditLoadingStates[credentialID] = true; + passkeyMessage.value = ''; + passkeySuccess.value = false; + try { + await authStore.updatePasskeyName(credentialID, editingPasskeyName.value.trim()); + passkeyMessage.value = t('settings.passkey.success.nameUpdated'); + passkeySuccess.value = true; + await authStore.fetchPasskeys(); + cancelEditPasskeyName(); + } catch (error: any) { + console.error(`更新 Passkey ${credentialID} 名称失败:`, error); + passkeyMessage.value = error.response?.data?.message || error.message || t('settings.passkey.error.nameUpdateFailed', '更新 Passkey 名称失败。'); + passkeySuccess.value = false; + } finally { + passkeyEditLoadingStates[credentialID] = false; + } + }; + + const handleDeletePasskey = async (credentialID: string) => { + if (editingPasskeyId.value === credentialID) { + cancelEditPasskeyName(); + } + if (!credentialID || typeof credentialID !== 'string') { + console.error('Attempted to delete a passkey with an invalid or undefined credentialID:', credentialID); + passkeyDeleteError.value = t('settings.passkey.error.deleteFailedInvalidId', '删除失败:无效的凭证 ID。'); + return; + } + // It's better to handle confirmation in the component itself if needed, or pass a confirm function + // For now, assuming confirmation is handled or not strictly needed in the composable. + // if (!confirm(t('settings.passkey.confirmDelete'))) return; + + passkeyDeleteLoadingStates[credentialID] = true; + passkeyDeleteError.value = null; + passkeyMessage.value = ''; + try { + await authStore.deletePasskey(credentialID); + passkeyMessage.value = t('settings.passkey.success.deleted'); + passkeySuccess.value = true; + // authStore.fetchPasskeys() is usually called within deletePasskey in the store + } catch (error: any) { + console.error(`删除 Passkey ${credentialID} 失败:`, error); + passkeyDeleteError.value = error.response?.data?.message || error.message || t('settings.passkey.error.deleteFailedGeneral'); + passkeySuccess.value = false; + } finally { + passkeyDeleteLoadingStates[credentialID] = false; + } + }; + + const formatDate = (dateInput: string | number | Date | undefined): string => { + if (!dateInput) return t('statusMonitor.notAvailable', 'N/A'); + try { + const date = new Date(typeof dateInput === 'number' ? dateInput * 1000 : dateInput); + return !isNaN(date.getTime()) ? date.toLocaleString() : t('statusMonitor.notAvailable', 'N/A'); + } catch (e) { + console.error("Error formatting date:", e); + return t('statusMonitor.notAvailable', 'N/A'); + } + }; + + // Fetch passkeys on composable initialization if user is authenticated + if (authStore.isAuthenticated) { + authStore.fetchPasskeys(); + } + + return { + passkeys, // from store + passkeysLoading, // from store + passkeyRegistrationLoading, + passkeyMessage, + passkeySuccess, + passkeyDeleteLoadingStates, + passkeyDeleteError, + editingPasskeyId, + editingPasskeyName, + passkeyEditLoadingStates, + handleRegisterNewPasskey, + startEditPasskeyName, + cancelEditPasskeyName, + savePasskeyName, + handleDeletePasskey, + formatDate, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useSystemSettings.ts b/packages/frontend/src/composables/settings/useSystemSettings.ts new file mode 100644 index 0000000..590b385 --- /dev/null +++ b/packages/frontend/src/composables/settings/useSystemSettings.ts @@ -0,0 +1,200 @@ +import { ref, watch } from 'vue'; +import { useSettingsStore } from '../../stores/settings.store'; +import { useI18n } from 'vue-i18n'; +import { storeToRefs } from 'pinia'; +import { availableLocales } from '../../i18n'; // 导入可用语言列表 + +export function useSystemSettings() { + const settingsStore = useSettingsStore(); + const { t } = useI18n(); + const { + settings, + language: storeLanguage, + statusMonitorIntervalSecondsNumber, + dockerDefaultExpandBoolean, // Assuming this comes from settings store + } = storeToRefs(settingsStore); + + // --- Language --- + const selectedLanguage = ref(storeLanguage.value); + const languageLoading = ref(false); + const languageMessage = ref(''); + const languageSuccess = ref(false); + const languageNames: Record = { + 'en-US': 'English', + 'zh-CN': '中文', + 'ja-JP': '日本語', + }; + + const handleUpdateLanguage = async () => { + languageLoading.value = true; + languageMessage.value = ''; + languageSuccess.value = false; + try { + await settingsStore.updateSetting('language', selectedLanguage.value); + languageMessage.value = t('settings.language.success.saved'); + languageSuccess.value = true; + // The language change will be reflected globally by the i18n instance + // when settingsStore.language updates. + } catch (error: any) { + console.error('更新语言设置失败:', error); + languageMessage.value = error.message || t('settings.language.error.saveFailed'); + languageSuccess.value = false; + } finally { + languageLoading.value = false; + } + }; + + // --- Timezone --- + const selectedTimezone = ref('UTC'); + const timezoneLoading = ref(false); + const timezoneMessage = ref(''); + const timezoneSuccess = ref(false); + const commonTimezones = ref([ + 'UTC', + 'Etc/GMT+12', 'Pacific/Midway', 'Pacific/Honolulu', 'America/Anchorage', + 'America/Los_Angeles', 'America/Denver', 'America/Chicago', 'America/New_York', + 'America/Caracas', 'America/Halifax', 'America/Sao_Paulo', 'Atlantic/Azores', + 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow', + 'Asia/Dubai', 'Asia/Karachi', 'Asia/Dhaka', 'Asia/Bangkok', + 'Asia/Shanghai', 'Asia/Tokyo', 'Australia/Sydney', 'Pacific/Auckland', + 'Etc/GMT-14' + ]); + + const handleUpdateTimezone = async () => { + timezoneLoading.value = true; + timezoneMessage.value = ''; + timezoneSuccess.value = false; + try { + await settingsStore.updateSetting('timezone', selectedTimezone.value); + timezoneMessage.value = t('settings.timezone.success.saved'); + timezoneSuccess.value = true; + } catch (error: any) { + console.error('更新时区设置失败:', error); + timezoneMessage.value = error.message || t('settings.timezone.error.saveFailed'); + timezoneSuccess.value = false; + } finally { + timezoneLoading.value = false; + } + }; + + // --- Status Monitor --- + const statusMonitorIntervalLocal = ref(3); + const statusMonitorLoading = ref(false); + const statusMonitorMessage = ref(''); + const statusMonitorSuccess = ref(false); + + const handleUpdateStatusMonitorInterval = async () => { + statusMonitorLoading.value = true; + statusMonitorMessage.value = ''; + statusMonitorSuccess.value = false; + try { + const intervalValue = statusMonitorIntervalLocal.value; + if (isNaN(intervalValue) || intervalValue < 1 || !Number.isInteger(intervalValue)) { + throw new Error(t('settings.statusMonitor.error.invalidInterval')); + } + await settingsStore.updateSetting('statusMonitorIntervalSeconds', String(intervalValue)); + statusMonitorMessage.value = t('settings.statusMonitor.success.saved'); + statusMonitorSuccess.value = true; + } catch (error: any) { + console.error('更新状态监控间隔失败:', error); + statusMonitorMessage.value = error.message || t('settings.statusMonitor.error.saveFailed'); + statusMonitorSuccess.value = false; + } finally { + statusMonitorLoading.value = false; + } + }; + + // --- Docker Settings --- + const dockerInterval = ref(2); + const dockerExpandDefault = ref(false); + const dockerSettingsLoading = ref(false); + const dockerSettingsMessage = ref(''); + const dockerSettingsSuccess = ref(false); + + const handleUpdateDockerSettings = async () => { + dockerSettingsLoading.value = true; + dockerSettingsMessage.value = ''; + dockerSettingsSuccess.value = false; + try { + const intervalValue = dockerInterval.value; + if (isNaN(intervalValue) || intervalValue < 1) { + throw new Error(t('settings.docker.error.invalidInterval')); + } + await settingsStore.updateMultipleSettings({ + dockerStatusIntervalSeconds: String(intervalValue), + dockerDefaultExpand: dockerExpandDefault.value ? 'true' : 'false' + }); + dockerSettingsMessage.value = t('settings.docker.success.saved'); + dockerSettingsSuccess.value = true; + } catch (error: any) { + console.error('更新 Docker 设置失败:', error); + dockerSettingsMessage.value = error.message || t('settings.docker.error.saveFailed'); + dockerSettingsSuccess.value = false; + } finally { + dockerSettingsLoading.value = false; + } + }; + + // Watch for changes in settings from the store and update local refs + watch(settings, (newSettings) => { + if (newSettings) { + selectedLanguage.value = newSettings.language || 'en-US'; // Default to en-US + selectedTimezone.value = newSettings.timezone || 'UTC'; + statusMonitorIntervalLocal.value = parseInt(newSettings.statusMonitorIntervalSeconds || '3', 10); + dockerInterval.value = parseInt(newSettings.dockerStatusIntervalSeconds || '2', 10); + // dockerExpandDefault.value is already reactive from storeToRefs (dockerDefaultExpandBoolean) + // but we keep a local ref for the form v-model and sync it. + dockerExpandDefault.value = newSettings.dockerDefaultExpand === 'true'; + } + }, { deep: true, immediate: true }); + + // Sync local dockerExpandDefault with the store's boolean getter + watch(dockerDefaultExpandBoolean, (newValue) => { + dockerExpandDefault.value = newValue; + }, { immediate: true }); + + // Sync local statusMonitorIntervalLocal with the store's number getter + watch(statusMonitorIntervalSecondsNumber, (newValue) => { + statusMonitorIntervalLocal.value = newValue; + }, { immediate: true }); + + // Sync local selectedLanguage with the store's language getter + watch(storeLanguage, (newVal) => { + selectedLanguage.value = newVal; + }, { immediate: true }); + + + return { + // Language + selectedLanguage, + languageLoading, + languageMessage, + languageSuccess, + languageNames, + availableLocales, // Export for template + handleUpdateLanguage, + + // Timezone + selectedTimezone, + timezoneLoading, + timezoneMessage, + timezoneSuccess, + commonTimezones, + handleUpdateTimezone, + + // Status Monitor + statusMonitorIntervalLocal, + statusMonitorLoading, + statusMonitorMessage, + statusMonitorSuccess, + handleUpdateStatusMonitorInterval, + + // Docker Settings + dockerInterval, + dockerExpandDefault, + dockerSettingsLoading, + dockerSettingsMessage, + dockerSettingsSuccess, + handleUpdateDockerSettings, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useTwoFactorAuth.ts b/packages/frontend/src/composables/settings/useTwoFactorAuth.ts new file mode 100644 index 0000000..f2f7e41 --- /dev/null +++ b/packages/frontend/src/composables/settings/useTwoFactorAuth.ts @@ -0,0 +1,117 @@ +import { ref, computed, onMounted } from 'vue'; +import { useAuthStore } from '../../stores/auth.store'; +import apiClient from '../../utils/apiClient'; +import { useI18n } from 'vue-i18n'; + +export function useTwoFactorAuth() { + const authStore = useAuthStore(); + const { t } = useI18n(); + + const twoFactorEnabled = ref(false); + const twoFactorLoading = ref(false); + const twoFactorMessage = ref(''); + const twoFactorSuccess = ref(false); + const setupData = ref<{ secret: string; qrCodeUrl: string } | null>(null); + const verificationCode = ref(''); + const disablePassword = ref(''); + + const isSettingUp2FA = computed(() => setupData.value !== null); + + const checkTwoFactorStatus = async () => { + // Ensure user is loaded before checking 2FA status + if (!authStore.user) { + await authStore.checkAuthStatus(); // Attempt to load user if not already + } + twoFactorEnabled.value = authStore.user?.isTwoFactorEnabled ?? false; + }; + + const handleSetup2FA = async () => { + twoFactorMessage.value = ''; + twoFactorSuccess.value = false; + twoFactorLoading.value = true; + setupData.value = null; + verificationCode.value = ''; + try { + const response = await apiClient.post<{ secret: string; qrCodeUrl: string }>('/auth/2fa/setup'); + setupData.value = response.data; + } catch (error: any) { + console.error('开始设置 2FA 失败:', error); + twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.setupFailed'); + } finally { + twoFactorLoading.value = false; + } + }; + + const handleVerifyAndActivate2FA = async () => { + if (!setupData.value || !verificationCode.value) { + twoFactorMessage.value = t('settings.twoFactor.error.codeRequired'); + return; + } + twoFactorMessage.value = ''; + twoFactorSuccess.value = false; + twoFactorLoading.value = true; + try { + await apiClient.post('/auth/2fa/verify', { token: verificationCode.value }); + twoFactorMessage.value = t('settings.twoFactor.success.activated'); + twoFactorSuccess.value = true; + twoFactorEnabled.value = true; + setupData.value = null; + verificationCode.value = ''; + await authStore.checkAuthStatus(); // Refresh user data + } catch (error: any) { + console.error('验证并激活 2FA 失败:', error); + twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.verificationFailed'); + } finally { + twoFactorLoading.value = false; + } + }; + + const handleDisable2FA = async () => { + if (!disablePassword.value) { + twoFactorMessage.value = t('settings.twoFactor.error.passwordRequiredForDisable'); + return; + } + twoFactorMessage.value = ''; + twoFactorSuccess.value = false; + twoFactorLoading.value = true; + try { + await apiClient.delete('/auth/2fa', { data: { password: disablePassword.value } }); + twoFactorMessage.value = t('settings.twoFactor.success.disabled'); + twoFactorSuccess.value = true; + twoFactorEnabled.value = false; + disablePassword.value = ''; + await authStore.checkAuthStatus(); // Refresh user data + } catch (error: any) { + console.error('禁用 2FA 失败:', error); + twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.disableFailed'); + } finally { + twoFactorLoading.value = false; + } + }; + + const cancelSetup = () => { + setupData.value = null; + verificationCode.value = ''; + twoFactorMessage.value = ''; + }; + + onMounted(async () => { + await checkTwoFactorStatus(); + }); + + return { + twoFactorEnabled, + twoFactorLoading, + twoFactorMessage, + twoFactorSuccess, + setupData, + verificationCode, + disablePassword, + isSettingUp2FA, + checkTwoFactorStatus, // Expose if needed externally, though onMounted handles initial check + handleSetup2FA, + handleVerifyAndActivate2FA, + handleDisable2FA, + cancelSetup, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useVersionCheck.ts b/packages/frontend/src/composables/settings/useVersionCheck.ts new file mode 100644 index 0000000..fcbd134 --- /dev/null +++ b/packages/frontend/src/composables/settings/useVersionCheck.ts @@ -0,0 +1,51 @@ +import { ref, computed } from 'vue'; +import axios from 'axios'; +import pkg from '../../../package.json'; // 调整路径以正确导入 package.json +import { useI18n } from 'vue-i18n'; + +export function useVersionCheck() { + const { t } = useI18n(); + const appVersion = ref(pkg.version); + const latestVersion = ref(null); + const isCheckingVersion = ref(false); + const versionCheckError = ref(null); + + const isUpdateAvailable = computed(() => { + // 简单的字符串比较,假设 tag 格式为 vX.Y.Z + return latestVersion.value && latestVersion.value !== `v${appVersion.value}`; + }); + + const checkLatestVersion = async () => { + isCheckingVersion.value = true; + versionCheckError.value = null; + latestVersion.value = null; + try { + const response = await axios.get('https://api.github.com/repos/Heavrnl/nexus-terminal/releases/latest'); + if (response.data && response.data.tag_name) { + latestVersion.value = response.data.tag_name; + } else { + throw new Error('Invalid API response format'); + } + } catch (error: any) { + console.error('检查最新版本失败:', error); + if (axios.isAxiosError(error) && error.response?.status === 404) { + versionCheckError.value = t('settings.about.error.noReleases'); + } else if (axios.isAxiosError(error) && error.response?.status === 403) { + versionCheckError.value = t('settings.about.error.rateLimit'); + } else { + versionCheckError.value = t('settings.about.error.checkFailed'); + } + } finally { + isCheckingVersion.value = false; + } + }; + + return { + appVersion, + latestVersion, + isCheckingVersion, + versionCheckError, + isUpdateAvailable, + checkLatestVersion, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/composables/settings/useWorkspaceSettings.ts b/packages/frontend/src/composables/settings/useWorkspaceSettings.ts new file mode 100644 index 0000000..d3da3c1 --- /dev/null +++ b/packages/frontend/src/composables/settings/useWorkspaceSettings.ts @@ -0,0 +1,306 @@ +import { ref, watch } from 'vue'; +import { useSettingsStore } from '../../stores/settings.store'; +import { useI18n } from 'vue-i18n'; +import { storeToRefs } from 'pinia'; + +export function useWorkspaceSettings() { + const settingsStore = useSettingsStore(); + const { t } = useI18n(); + + const { + showPopupFileEditorBoolean, + shareFileEditorTabsBoolean, + autoCopyOnSelectBoolean, + workspaceSidebarPersistentBoolean, + commandInputSyncTarget, + showConnectionTagsBoolean, + showQuickCommandTagsBoolean, + terminalScrollbackLimitNumber, + fileManagerShowDeleteConfirmationBoolean, + } = storeToRefs(settingsStore); + + // --- Popup Editor --- + const popupEditorEnabled = ref(true); + const popupEditorLoading = ref(false); + const popupEditorMessage = ref(''); + const popupEditorSuccess = ref(false); + + const handleUpdatePopupEditorSetting = async () => { + popupEditorLoading.value = true; + popupEditorMessage.value = ''; + popupEditorSuccess.value = false; + try { + const valueToSave = popupEditorEnabled.value ? 'true' : 'false'; + await settingsStore.updateSetting('showPopupFileEditor', valueToSave); + popupEditorMessage.value = t('settings.popupEditor.success.saved'); + popupEditorSuccess.value = true; + } catch (error: any) { + console.error('更新弹窗编辑器设置失败:', error); + popupEditorMessage.value = error.message || t('settings.popupEditor.error.saveFailed'); + popupEditorSuccess.value = false; + } finally { + popupEditorLoading.value = false; + } + }; + + // --- Share Editor Tabs --- + const shareTabsEnabled = ref(true); + const shareTabsLoading = ref(false); + const shareTabsMessage = ref(''); + const shareTabsSuccess = ref(false); + + const handleUpdateShareTabsSetting = async () => { + shareTabsLoading.value = true; + shareTabsMessage.value = ''; + shareTabsSuccess.value = false; + try { + const valueToSave = shareTabsEnabled.value ? 'true' : 'false'; + await settingsStore.updateSetting('shareFileEditorTabs', valueToSave); + shareTabsMessage.value = t('settings.shareEditorTabs.success.saved'); + shareTabsSuccess.value = true; + } catch (error: any) { + console.error('更新共享编辑器标签页设置失败:', error); + shareTabsMessage.value = error.message || t('settings.shareEditorTabs.error.saveFailed'); + shareTabsSuccess.value = false; + } finally { + shareTabsLoading.value = false; + } + }; + + // --- Auto Copy on Select --- + const autoCopyEnabled = ref(false); + const autoCopyLoading = ref(false); + const autoCopyMessage = ref(''); + const autoCopySuccess = ref(false); + + const handleUpdateAutoCopySetting = async () => { + autoCopyLoading.value = true; + autoCopyMessage.value = ''; + autoCopySuccess.value = false; + try { + const valueToSave = autoCopyEnabled.value ? 'true' : 'false'; + await settingsStore.updateSetting('autoCopyOnSelect', valueToSave); + autoCopyMessage.value = t('settings.autoCopyOnSelect.success.saved'); + autoCopySuccess.value = true; + } catch (error: any) { + console.error('更新自动复制设置失败:', error); + autoCopyMessage.value = error.message || t('settings.autoCopyOnSelect.error.saveFailed'); + autoCopySuccess.value = false; + } finally { + autoCopyLoading.value = false; + } + }; + + // --- Workspace Sidebar Persistent --- + const workspaceSidebarPersistentEnabled = ref(false); + const workspaceSidebarPersistentLoading = ref(false); + const workspaceSidebarPersistentMessage = ref(''); + const workspaceSidebarPersistentSuccess = ref(false); + + const handleUpdateWorkspaceSidebarSetting = async () => { + workspaceSidebarPersistentLoading.value = true; + workspaceSidebarPersistentMessage.value = ''; + workspaceSidebarPersistentSuccess.value = false; + try { + const valueToSave = workspaceSidebarPersistentEnabled.value ? 'true' : 'false'; + await settingsStore.updateSetting('workspaceSidebarPersistent', valueToSave); + workspaceSidebarPersistentMessage.value = t('settings.workspace.success.sidebarPersistentSaved'); + workspaceSidebarPersistentSuccess.value = true; + } catch (error: any) { + console.error('更新侧边栏固定设置失败:', error); + workspaceSidebarPersistentMessage.value = error.message || t('settings.workspace.error.sidebarPersistentSaveFailed'); + workspaceSidebarPersistentSuccess.value = false; + } finally { + workspaceSidebarPersistentLoading.value = false; + } + }; + + // --- Command Input Sync Target --- + const commandInputSyncTargetLocal = ref<'none' | 'quickCommands' | 'commandHistory'>('none'); + const commandInputSyncLoading = ref(false); + const commandInputSyncMessage = ref(''); + const commandInputSyncSuccess = ref(false); + + const handleUpdateCommandInputSyncTarget = async () => { + commandInputSyncLoading.value = true; + commandInputSyncMessage.value = ''; + commandInputSyncSuccess.value = false; + try { + await settingsStore.updateSetting('commandInputSyncTarget', commandInputSyncTargetLocal.value); + commandInputSyncMessage.value = t('settings.commandInputSync.success.saved', '同步目标已保存'); + commandInputSyncSuccess.value = true; + } catch (error: any) { + console.error('更新命令输入同步目标失败:', error); + commandInputSyncMessage.value = error.message || t('settings.commandInputSync.error.saveFailed', '保存同步目标失败'); + commandInputSyncSuccess.value = false; + } finally { + commandInputSyncLoading.value = false; + } + }; + + // --- Show Connection Tags --- + const showConnectionTagsLocal = ref(true); + const showConnectionTagsLoading = ref(false); + const showConnectionTagsMessage = ref(''); + const showConnectionTagsSuccess = ref(false); + + const handleUpdateShowConnectionTags = async () => { + showConnectionTagsLoading.value = true; + showConnectionTagsMessage.value = ''; + showConnectionTagsSuccess.value = false; + try { + await settingsStore.updateSetting('showConnectionTags', showConnectionTagsLocal.value); + showConnectionTagsMessage.value = t('settings.workspace.success.showConnectionTagsSaved', '连接标签显示设置已保存'); + showConnectionTagsSuccess.value = true; + } catch (error: any) { + console.error('更新显示连接标签设置失败:', error); + showConnectionTagsMessage.value = error.message || t('settings.workspace.error.showConnectionTagsSaveFailed', '保存连接标签显示设置失败'); + showConnectionTagsSuccess.value = false; + } finally { + showConnectionTagsLoading.value = false; + } + }; + + // --- Show Quick Command Tags --- + const showQuickCommandTagsLocal = ref(true); + const showQuickCommandTagsLoading = ref(false); + const showQuickCommandTagsMessage = ref(''); + const showQuickCommandTagsSuccess = ref(false); + + const handleUpdateShowQuickCommandTags = async () => { + showQuickCommandTagsLoading.value = true; + showQuickCommandTagsMessage.value = ''; + showQuickCommandTagsSuccess.value = false; + try { + await settingsStore.updateSetting('showQuickCommandTags', showQuickCommandTagsLocal.value); + showQuickCommandTagsMessage.value = t('settings.workspace.success.showQuickCommandTagsSaved', '快捷指令标签显示设置已保存'); + showQuickCommandTagsSuccess.value = true; + } catch (error: any) { + console.error('更新显示快捷指令标签设置失败:', error); + showQuickCommandTagsMessage.value = error.message || t('settings.workspace.error.showQuickCommandTagsSaveFailed', '保存快捷指令标签显示设置失败'); + showQuickCommandTagsSuccess.value = false; + } finally { + showQuickCommandTagsLoading.value = false; + } + }; + + // --- Terminal Scrollback Limit --- + const terminalScrollbackLimitLocal = ref(null); + const terminalScrollbackLimitLoading = ref(false); + const terminalScrollbackLimitMessage = ref(''); + const terminalScrollbackLimitSuccess = ref(false); + + const handleUpdateTerminalScrollbackLimit = async () => { + terminalScrollbackLimitLoading.value = true; + terminalScrollbackLimitMessage.value = ''; + terminalScrollbackLimitSuccess.value = false; + try { + const limitValue = terminalScrollbackLimitLocal.value; + if (limitValue !== null && limitValue !== undefined && (isNaN(limitValue) || !Number.isInteger(limitValue) || limitValue < 0)) { + throw new Error(t('settings.terminalScrollback.error.invalidInput', '请输入一个有效的非负整数。')); + } + const valueToSave = (limitValue === null || limitValue === undefined) ? '5000' : String(limitValue); + await settingsStore.updateSetting('terminalScrollbackLimit', valueToSave); + terminalScrollbackLimitMessage.value = t('settings.terminalScrollback.success.saved', '终端回滚行数设置已保存。'); + terminalScrollbackLimitSuccess.value = true; + } catch (error: any) { + console.error('更新终端回滚行数设置失败:', error); + terminalScrollbackLimitMessage.value = error.message || t('settings.terminalScrollback.error.saveFailed', '保存终端回滚行数设置失败。'); + terminalScrollbackLimitSuccess.value = false; + } finally { + terminalScrollbackLimitLoading.value = false; + } + }; + + // --- File Manager Delete Confirmation --- + const fileManagerShowDeleteConfirmationLocal = ref(true); + const fileManagerShowDeleteConfirmationLoading = ref(false); + const fileManagerShowDeleteConfirmationMessage = ref(''); + const fileManagerShowDeleteConfirmationSuccess = ref(false); + + const handleUpdateFileManagerDeleteConfirmation = async () => { + fileManagerShowDeleteConfirmationLoading.value = true; + fileManagerShowDeleteConfirmationMessage.value = ''; + fileManagerShowDeleteConfirmationSuccess.value = false; + try { + const valueToSave = fileManagerShowDeleteConfirmationLocal.value ? 'true' : 'false'; + await settingsStore.updateSetting('fileManagerShowDeleteConfirmation', valueToSave); + fileManagerShowDeleteConfirmationMessage.value = t('settings.workspace.fileManagerDeleteConfirmSuccess', '文件管理器删除确认设置已保存。'); + fileManagerShowDeleteConfirmationSuccess.value = true; + } catch (error: any) { + console.error('更新文件管理器删除确认设置失败:', error); + fileManagerShowDeleteConfirmationMessage.value = error.message || t('settings.workspace.fileManagerDeleteConfirmError', '保存文件管理器删除确认设置失败。'); + fileManagerShowDeleteConfirmationSuccess.value = false; + } finally { + fileManagerShowDeleteConfirmationLoading.value = false; + } + }; + + // Watchers to sync local state with store state + watch(showPopupFileEditorBoolean, (newValue) => { popupEditorEnabled.value = newValue; }, { immediate: true }); + watch(shareFileEditorTabsBoolean, (newValue) => { shareTabsEnabled.value = newValue; }, { immediate: true }); + watch(autoCopyOnSelectBoolean, (newValue) => { autoCopyEnabled.value = newValue; }, { immediate: true }); + watch(workspaceSidebarPersistentBoolean, (newValue) => { workspaceSidebarPersistentEnabled.value = newValue; }, { immediate: true }); + watch(commandInputSyncTarget, (newValue) => { commandInputSyncTargetLocal.value = newValue; }, { immediate: true }); + watch(showConnectionTagsBoolean, (newValue) => { showConnectionTagsLocal.value = newValue; }, { immediate: true }); + watch(showQuickCommandTagsBoolean, (newValue) => { showQuickCommandTagsLocal.value = newValue; }, { immediate: true }); + watch(terminalScrollbackLimitNumber, (newValue) => { terminalScrollbackLimitLocal.value = newValue; }, { immediate: true }); + watch(fileManagerShowDeleteConfirmationBoolean, (newValue) => { fileManagerShowDeleteConfirmationLocal.value = newValue; }, { immediate: true }); + + + return { + popupEditorEnabled, + popupEditorLoading, + popupEditorMessage, + popupEditorSuccess, + handleUpdatePopupEditorSetting, + + shareTabsEnabled, + shareTabsLoading, + shareTabsMessage, + shareTabsSuccess, + handleUpdateShareTabsSetting, + + autoCopyEnabled, + autoCopyLoading, + autoCopyMessage, + autoCopySuccess, + handleUpdateAutoCopySetting, + + workspaceSidebarPersistentEnabled, + workspaceSidebarPersistentLoading, + workspaceSidebarPersistentMessage, + workspaceSidebarPersistentSuccess, + handleUpdateWorkspaceSidebarSetting, + + commandInputSyncTargetLocal, + commandInputSyncLoading, + commandInputSyncMessage, + commandInputSyncSuccess, + handleUpdateCommandInputSyncTarget, + + showConnectionTagsLocal, + showConnectionTagsLoading, + showConnectionTagsMessage, + showConnectionTagsSuccess, + handleUpdateShowConnectionTags, + + showQuickCommandTagsLocal, + showQuickCommandTagsLoading, + showQuickCommandTagsMessage, + showQuickCommandTagsSuccess, + handleUpdateShowQuickCommandTags, + + terminalScrollbackLimitLocal, + terminalScrollbackLimitLoading, + terminalScrollbackLimitMessage, + terminalScrollbackLimitSuccess, + handleUpdateTerminalScrollbackLimit, + + fileManagerShowDeleteConfirmationLocal, + fileManagerShowDeleteConfirmationLoading, + fileManagerShowDeleteConfirmationMessage, + fileManagerShowDeleteConfirmationSuccess, + handleUpdateFileManagerDeleteConfirmation, + }; +} \ No newline at end of file diff --git a/packages/frontend/src/views/SettingsView.vue b/packages/frontend/src/views/SettingsView.vue index 6a098bd..a4bba59 100644 --- a/packages/frontend/src/views/SettingsView.vue +++ b/packages/frontend/src/views/SettingsView.vue @@ -63,12 +63,12 @@

{{ $t('settings.passkey.registeredKeysTitle') }}

-
+
{{ $t('common.loading') }}
-
+
    -
  • +
  • @@ -727,53 +727,219 @@
    -
+
-
- - + + +