refactor:优化settings代码结构
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const isCheckingVersion = ref(false);
|
||||
const versionCheckError = ref<string | null>(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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<UpdateCaptchaSettingsDto>({
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]*=(?:(?:["'])(?<quoted>.*?)\1|(?<unquoted>[^;\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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const blacklistDeleteLoading = ref(false);
|
||||
const blacklistDeleteError = ref<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
const passkeyDeleteError = ref<string | null>(null);
|
||||
|
||||
// State for editing passkey name
|
||||
const editingPasskeyId = ref<string | null>(null);
|
||||
const editingPasskeyName = ref('');
|
||||
const passkeyEditLoadingStates = reactive<Record<string, boolean>>({});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string>(storeLanguage.value);
|
||||
const languageLoading = ref(false);
|
||||
const languageMessage = ref('');
|
||||
const languageSuccess = ref(false);
|
||||
const languageNames: Record<string, string> = {
|
||||
'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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const isCheckingVersion = ref(false);
|
||||
const versionCheckError = ref<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<number | null>(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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user