This commit is contained in:
Baobhan Sith
2025-04-15 18:59:56 +08:00
parent 7649a7b69d
commit c026a42d06
43 changed files with 3479 additions and 169 deletions
+2
View File
@@ -21,6 +21,8 @@ const handleLogout = () => {
<RouterLink to="/connections">{{ t('nav.connections') }}</RouterLink> |
<RouterLink to="/proxies">{{ t('nav.proxies') }}</RouterLink> | <!-- 新增代理链接 -->
<RouterLink to="/tags">{{ t('nav.tags') }}</RouterLink> | <!-- 新增标签链接 -->
<RouterLink to="/notifications">{{ t('nav.notifications') }}</RouterLink> | <!-- 新增通知链接 -->
<RouterLink to="/audit-logs">{{ t('nav.auditLogs') }}</RouterLink> | <!-- 新增审计日志链接 -->
<RouterLink to="/settings">{{ t('nav.settings') }}</RouterLink> | <!-- 新增设置链接 -->
<RouterLink v-if="!isAuthenticated" to="/login">{{ t('nav.login') }}</RouterLink>
<a href="#" v-if="isAuthenticated" @click.prevent="handleLogout">{{ t('nav.logout') }}</a>
+137 -15
View File
@@ -46,6 +46,7 @@ const {
readFile, // 暴露给 useFileEditor
writeFile, // 暴露给 useFileEditor
joinPath, // 从 composable 获取 joinPath
clearSftpError, // 导入清除错误的函数
} = useSftpActions(currentPath); // 传入 currentPath ref
// 文件上传模块
@@ -84,6 +85,9 @@ const sortKey = ref<keyof FileListItem | 'type' | 'size' | 'mtime'>('filename');
const sortDirection = ref<'asc' | 'desc'>('asc'); // 排序方向
const initialLoadDone = ref(false); // Track if the initial load has been triggered
const isFetchingInitialPath = ref(false); // Track if fetching realpath
const isEditingPath = ref(false); // State for path editing mode
const pathInputRef = ref<HTMLInputElement | null>(null); // Ref for the path input element
const editablePath = ref(''); // Temp storage for the path being edited
// --- Column Resizing State ---
const tableRef = ref<HTMLTableElement | null>(null);
@@ -273,6 +277,11 @@ const handleItemClick = (event: MouseEvent, item: FileListItem) => {
}
if (item.attrs.isDirectory) {
// 检查是否已在加载,防止快速重复点击
if (isLoading.value) {
console.log('[文件管理器] 忽略目录点击,因为正在加载...');
return;
}
// 处理目录点击:导航
const newPath = item.filename === '..'
? currentPath.value.substring(0, currentPath.value.lastIndexOf('/')) || '/'
@@ -596,6 +605,7 @@ const getColumnKeyByIndex = (index: number): keyof typeof colWidths.value | null
};
const startResize = (event: MouseEvent, index: number) => {
event.stopPropagation(); // Stop the event from bubbling up to the th's click handler
event.preventDefault(); // Prevent text selection during drag
isResizing.value = true;
resizingColumnIndex.value = index;
@@ -639,21 +649,77 @@ const stopResize = () => {
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
document.body.style.cursor = ''; // Reset cursor
document.body.style.userSelect = ''; // Reset text selection
document.body.style.userSelect = ''; // Reset text selection
}
};
// --- Path Editing Logic ---
const startPathEdit = () => {
if (isLoading.value || !props.isConnected) return; // Don't allow edit while loading or disconnected
editablePath.value = currentPath.value; // Initialize input with current path
isEditingPath.value = true;
nextTick(() => {
pathInputRef.value?.focus(); // Focus the input after it becomes visible
pathInputRef.value?.select(); // Select the text
});
};
const handlePathInput = async (event?: Event) => {
// Check if triggered by blur or Enter key
if (event && event instanceof KeyboardEvent && event.key !== 'Enter') {
return; // Ignore other key presses
}
const newPath = editablePath.value.trim();
isEditingPath.value = false; // Exit editing mode immediately
if (newPath === currentPath.value || !newPath) {
return; // No change or empty path, do nothing
}
console.log(`[文件管理器] 尝试导航到新路径: ${newPath}`);
// Call loadDirectory which handles path validation via backend
await loadDirectory(newPath);
// If loadDirectory resulted in an error (handled within useSftpActions),
// the currentPath will not have changed, effectively reverting the UI.
// If successful, currentPath is updated by loadDirectory, and the UI reflects the new path.
};
const cancelPathEdit = () => {
isEditingPath.value = false;
// No need to reset editablePath, it will be set on next edit start
};
// Function to clear the error message - now calls the composable's function
const clearError = () => {
clearSftpError();
};
</script>
<template>
<div class="file-manager"> <!-- Removed @click handler -->
<div class="file-manager">
<div class="toolbar">
<div class="path-bar">
{{ t('fileManager.currentPath') }}: <strong>{{ currentPath }}</strong>
<button @click="loadDirectory(currentPath)" :disabled="isLoading || !isConnected" :title="t('fileManager.actions.refresh')">🔄</button>
<span v-show="!isEditingPath">
{{ t('fileManager.currentPath') }}: <strong @click="startPathEdit" :title="t('fileManager.editPathTooltip')" class="editable-path">{{ currentPath }}</strong>
</span>
<input
v-show="isEditingPath"
ref="pathInputRef"
type="text"
v-model="editablePath"
class="path-input"
@keyup.enter="handlePathInput"
@blur="handlePathInput"
@keyup.esc="cancelPathEdit"
/>
<button @click.stop="loadDirectory(currentPath)" :disabled="isLoading || !isConnected || isEditingPath" :title="t('fileManager.actions.refresh')">🔄</button>
<!-- Pass event to handleItemClick for '..' -->
<button @click="handleItemClick($event, { filename: '..', longname: '', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })" :disabled="isLoading || !isConnected || currentPath === '/'" :title="t('fileManager.actions.parentDirectory')"></button>
<button @click.stop="handleItemClick($event, { filename: '..', longname: '', attrs: { isDirectory: true, isFile: false, isSymbolicLink: false, size: 0, uid: 0, gid: 0, mode: 0, atime: 0, mtime: 0 } })" :disabled="isLoading || !isConnected || currentPath === '/' || isEditingPath" :title="t('fileManager.actions.parentDirectory')"></button>
</div>
<div class="actions-bar">
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple style="display: none;" />
@@ -672,17 +738,28 @@ const stopResize = () => {
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<!-- Error Alert Box -->
<div v-if="error" class="error-alert">
<span>{{ error }}</span>
<button @click="clearError" class="close-error-btn" :title="t('common.dismiss')">&times;</button> <!-- Use clearSftpError -->
</div>
<!-- 1. Initial Loading Indicator -->
<div v-if="isLoading && !initialLoadDone" class="loading">{{ t('fileManager.loading') }}</div>
<!-- 2. Error Indicator -->
<div v-else-if="error" class="error">{{ error }}</div>
<!-- 3. File Table (Show if not initial loading, no error, and there's something to display: either files or '..') -->
<!-- 2. File Table (Show if not initial loading) -->
<!-- Removed the error condition here, table shows regardless of error -->
<table v-else-if="sortedFileList.length > 0 || currentPath !== '/'" ref="tableRef" class="resizable-table" @contextmenu.prevent>
<!-- Temporarily removed colgroup for debugging -->
<colgroup>
<col :style="{ width: `${colWidths.type}px` }">
<col :style="{ width: `${colWidths.name}px` }">
<col :style="{ width: `${colWidths.size}px` }">
<col :style="{ width: `${colWidths.permissions}px` }">
<col :style="{ width: `${colWidths.modified}px` }">
</colgroup>
<thead>
<tr>
<!-- Remove width style from th, controlled by colgroup -->
<th @click="handleSort('type')" class="sortable">
{{ t('fileManager.headers.type') }}
<span v-if="sortKey === 'type'">{{ sortDirection === 'asc' ? '' : '' }}</span>
@@ -733,7 +810,11 @@ const stopResize = () => {
</table>
<!-- 4. Empty Directory Message (Show if not initial loading, no error, list is empty, and at root) -->
<div v-else class="no-files">{{ t('fileManager.emptyDirectory') }}</div>
<!-- 3. Empty Directory Message (Show only if not loading AND list is empty AND not at root) -->
<div v-else-if="!isLoading && sortedFileList.length === 0 && currentPath === '/'" class="no-files">{{ t('fileManager.emptyDirectory') }}</div>
<!-- Note: If there's an error, the table will still render (potentially empty if initial load failed),
but the error message will be shown above. The "Empty Directory" message
is now only shown if explicitly empty and not loading. -->
</div>
<!-- 使用 FileUploadPopup 组件 -->
@@ -775,8 +856,28 @@ const stopResize = () => {
/* Styles remain the same, but add .selected style */
.file-manager { height: 100%; display: flex; flex-direction: column; font-family: sans-serif; font-size: 0.9rem; overflow: hidden; }
.toolbar { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; background-color: #f0f0f0; border-bottom: 1px solid #ccc; flex-wrap: wrap; }
.path-bar { white-space: nowrap; overflow-x: auto; flex-grow: 1; margin-right: 1rem; }
.path-bar button { margin-left: 0.5rem; background: none; border: none; cursor: pointer; font-size: 1.1em; padding: 0.1rem 0.3rem; }
.path-bar { white-space: nowrap; overflow-x: auto; flex-grow: 1; margin-right: 1rem; padding: 0.2rem 0.4rem; border-radius: 3px; } /* Remove cursor:text and hover */
.path-bar strong.editable-path {
font-weight: normal;
background-color: #e0e0e0;
padding: 0.1rem 0.4rem;
border-radius: 3px;
margin-left: 0.3rem;
cursor: text; /* Add cursor only to the clickable part */
}
.path-bar strong.editable-path:hover {
background-color: #d0d0d0; /* Slightly darker hover for the path */
}
.path-input {
font-family: inherit;
font-size: inherit;
border: 1px solid #ccc;
padding: 0.1rem 0.4rem;
border-radius: 3px;
width: calc(100% - 70px); /* Adjust width based on button sizes */
box-sizing: border-box;
}
.path-bar button { margin-left: 0.5rem; background: none; border: none; cursor: pointer; font-size: 1.1em; padding: 0.1rem 0.3rem; vertical-align: middle; }
.path-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
.actions-bar button { padding: 0.3rem 0.6rem; cursor: pointer; margin-left: 0.5rem; }
.actions-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -787,8 +888,29 @@ const stopResize = () => {
.upload-popup progress { margin: 0 0.5rem; width: 80px; height: 0.8em; }
.upload-popup .error { color: red; margin-left: 0.5rem; flex-basis: 100%; font-size: 0.8em; }
.upload-popup .cancel-btn { margin-left: auto; padding: 0.1rem 0.4rem; font-size: 0.8em; background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; cursor: pointer; }
.loading, .error, .no-files { padding: 1rem; text-align: center; color: #666; }
.error { color: red; }
.loading, .no-files { padding: 1rem; text-align: center; color: #666; }
/* Removed .error style for the main container */
.error-alert {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
padding: 0.75rem 1.25rem;
margin: 0.5rem;
border-radius: 0.25rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.close-error-btn {
background: none;
border: none;
color: inherit;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
padding: 0 0.5rem;
line-height: 1;
}
.file-list-container { flex-grow: 1; overflow-y: auto; position: relative; /* Needed for overlay */ }
.file-list-container.drag-over {
outline: 2px dashed #007bff; /* Blue dashed outline */
@@ -0,0 +1,398 @@
<template>
<form @submit.prevent="handleSubmit" class="notification-setting-form">
<h3>{{ isEditing ? $t('settings.notifications.form.editTitle') : $t('settings.notifications.form.addTitle') }}</h3>
<div class="mb-3">
<label for="setting-name" class="form-label">{{ $t('settings.notifications.form.name') }}</label>
<input type="text" id="setting-name" v-model="formData.name" class="form-control" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" id="setting-enabled" v-model="formData.enabled" class="form-check-input">
<label for="setting-enabled" class="form-check-label">{{ $t('common.enabled') }}</label>
</div>
<div class="mb-3">
<label for="setting-channel-type" class="form-label">{{ $t('settings.notifications.form.channelType') }}</label>
<select id="setting-channel-type" v-model="formData.channel_type" class="form-select" required :disabled="isEditing">
<option value="webhook">{{ $t('settings.notifications.types.webhook') }}</option>
<option value="email">{{ $t('settings.notifications.types.email') }}</option>
<option value="telegram">{{ $t('settings.notifications.types.telegram') }}</option>
</select>
<small v-if="isEditing" class="text-muted">{{ $t('settings.notifications.form.channelTypeEditNote') }}</small>
</div>
<!-- Channel Specific Config -->
<div v-if="formData.channel_type === 'webhook'" class="channel-config mb-3 p-3 border rounded">
<h4>{{ $t('settings.notifications.types.webhook') }} {{ $t('common.settings') }}</h4>
<div class="mb-3">
<label for="webhook-url" class="form-label">URL</label>
<input type="url" id="webhook-url" v-model="webhookConfig.url" class="form-control" required>
</div>
<div class="mb-3">
<label for="webhook-method" class="form-label">{{ $t('settings.notifications.form.webhookMethod') }}</label>
<select id="webhook-method" v-model="webhookConfig.method" class="form-select">
<option value="POST">POST</option>
<option value="GET">GET</option>
<option value="PUT">PUT</option>
</select>
</div>
<div class="mb-3">
<label for="webhook-headers" class="form-label">{{ $t('settings.notifications.form.webhookHeaders') }} (JSON)</label>
<textarea id="webhook-headers" v-model="webhookHeadersString" class="form-control" rows="3" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer ..."}'></textarea>
<small v-if="headerError" class="text-danger">{{ headerError }}</small>
</div>
<div class="mb-3">
<label for="webhook-body" class="form-label">{{ $t('settings.notifications.form.webhookBodyTemplate') }}</label>
<textarea id="webhook-body" v-model="webhookConfig.bodyTemplate" class="form-control" rows="3" :placeholder="$t('settings.notifications.form.webhookBodyPlaceholder')"></textarea>
<small class="text-muted">{{ $t('settings.notifications.form.templateHelp') }}</small>
</div>
</div>
<div v-if="formData.channel_type === 'email'" class="channel-config mb-3 p-3 border rounded">
<h4>{{ $t('settings.notifications.types.email') }} {{ $t('common.settings') }}</h4>
<div class="mb-3">
<label for="email-to" class="form-label">{{ $t('settings.notifications.form.emailTo') }}</label>
<input type="email" id="email-to" v-model="emailConfig.to" class="form-control" required placeholder="recipient1@example.com, recipient2@example.com">
<small class="text-muted">{{ $t('settings.notifications.form.emailToHelp') }}</small>
</div>
<div class="mb-3">
<label for="email-subject" class="form-label">{{ $t('settings.notifications.form.emailSubjectTemplate') }}</label>
<input type="text" id="email-subject" v-model="emailConfig.subjectTemplate" class="form-control" :placeholder="$t('settings.notifications.form.emailSubjectPlaceholder')">
<small class="text-muted">{{ $t('settings.notifications.form.templateHelp') }}</small>
</div>
<!-- SMTP Settings -->
<div class="mb-3">
<label for="smtp-host" class="form-label">{{ $t('settings.notifications.form.smtpHost') }}</label>
<input type="text" id="smtp-host" v-model="emailConfig.smtpHost" class="form-control" required>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="smtp-port" class="form-label">{{ $t('settings.notifications.form.smtpPort') }}</label>
<input type="number" id="smtp-port" v-model.number="emailConfig.smtpPort" class="form-control" required>
</div>
<div class="col-md-6 mb-3 d-flex align-items-end">
<div class="form-check">
<input type="checkbox" id="smtp-secure" v-model="emailConfig.smtpSecure" class="form-check-input">
<label for="smtp-secure" class="form-check-label">{{ $t('settings.notifications.form.smtpSecure') }} (TLS)</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="smtp-user" class="form-label">{{ $t('settings.notifications.form.smtpUser') }}</label>
<input type="text" id="smtp-user" v-model="emailConfig.smtpUser" class="form-control">
</div>
<div class="mb-3">
<label for="smtp-pass" class="form-label">{{ $t('settings.notifications.form.smtpPass') }}</label>
<input type="password" id="smtp-pass" v-model="emailConfig.smtpPass" class="form-control">
</div>
<div class="mb-3">
<label for="smtp-from" class="form-label">{{ $t('settings.notifications.form.smtpFrom') }}</label>
<input type="email" id="smtp-from" v-model="emailConfig.from" class="form-control" required placeholder="sender@example.com">
<small class="text-muted">{{ $t('settings.notifications.form.smtpFromHelp') }}</small>
</div>
<button type="button" @click="handleTestNotification" class="btn btn-outline-secondary btn-sm" :disabled="!isEditing || testingNotification">
<span v-if="testingNotification" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{ testingNotification ? $t('common.testing') : $t('settings.notifications.form.testButton') }}
</button>
<small v-if="testResult" :class="['d-block mt-2', testResult.success ? 'text-success' : 'text-danger']">{{ testResult.message }}</small>
<small v-if="!isEditing" class="d-block mt-2 text-muted">{{ $t('settings.notifications.form.saveToTest') }}</small>
</div>
<div v-if="formData.channel_type === 'telegram'" class="channel-config mb-3 p-3 border rounded">
<h4>{{ $t('settings.notifications.types.telegram') }} {{ $t('common.settings') }}</h4>
<div class="mb-3">
<label for="telegram-token" class="form-label">{{ $t('settings.notifications.form.telegramToken') }}</label>
<input type="password" id="telegram-token" v-model="telegramConfig.botToken" class="form-control" required>
<small class="text-muted">{{ $t('settings.notifications.form.telegramTokenHelp') }}</small>
</div>
<div class="mb-3">
<label for="telegram-chatid" class="form-label">{{ $t('settings.notifications.form.telegramChatId') }}</label>
<input type="text" id="telegram-chatid" v-model="telegramConfig.chatId" class="form-control" required>
</div>
<div class="mb-3">
<label for="telegram-message" class="form-label">{{ $t('settings.notifications.form.telegramMessageTemplate') }}</label>
<textarea id="telegram-message" v-model="telegramConfig.messageTemplate" class="form-control" rows="3" :placeholder="$t('settings.notifications.form.telegramMessagePlaceholder')"></textarea>
<small class="text-muted">{{ $t('settings.notifications.form.templateHelp') }}</small>
</div>
</div>
<!-- Enabled Events -->
<div class="mb-3">
<label class="form-label">{{ $t('settings.notifications.form.enabledEvents') }}</label>
<div class="row">
<div v-for="event in allNotificationEvents" :key="event" class="col-md-4 col-sm-6">
<div class="form-check">
<input
type="checkbox"
:id="'event-' + event"
:value="event"
v-model="formData.enabled_events"
class="form-check-input"
>
<label :for="'event-' + event" class="form-check-label">{{ getEventDisplayName(event) }}</label>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" @click="handleCancel" class="btn btn-secondary me-2">{{ $t('common.cancel') }}</button>
<button type="submit" class="btn btn-primary" :disabled="store.isLoading || !!headerError || testingNotification">
{{ store.isLoading ? $t('common.saving') : $t('common.save') }}
</button>
</div>
<div v-if="formError" class="alert alert-danger mt-3">{{ formError }}</div>
<div v-if="testError" class="alert alert-danger mt-3">{{ testError }}</div>
</form>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, PropType, nextTick } from 'vue';
import { useNotificationsStore } from '../stores/notifications.store';
import {
NotificationSetting,
NotificationSettingData,
NotificationChannelType,
NotificationEvent,
WebhookConfig,
EmailConfig, // Keep this, but we'll add SMTP fields
TelegramConfig
} from '../types/server.types';
import { useI18n } from 'vue-i18n';
// Extend EmailConfig for SMTP fields
interface SmtpEmailConfig extends EmailConfig {
smtpHost?: string;
smtpPort?: number;
smtpSecure?: boolean;
smtpUser?: string;
smtpPass?: string;
from?: string; // Add 'from' address
}
const props = defineProps({
initialData: {
type: Object as PropType<NotificationSetting | null>,
default: null,
},
});
const emit = defineEmits(['save', 'cancel']);
const store = useNotificationsStore();
const { t } = useI18n();
const formError = ref<string | null>(null);
const headerError = ref<string | null>(null);
const testError = ref<string | null>(null);
const testingNotification = ref(false);
const testResult = ref<{ success: boolean; message: string } | null>(null);
const isEditing = computed(() => !!props.initialData?.id);
// Define all possible events
const allNotificationEvents: NotificationEvent[] = [
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'CONNECTION_ADDED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED',
'SETTINGS_UPDATED', 'PROXY_ADDED', 'PROXY_UPDATED', 'PROXY_DELETED', 'TAG_ADDED', 'TAG_UPDATED',
'TAG_DELETED', 'API_KEY_ADDED', 'API_KEY_DELETED', 'PASSKEY_ADDED', 'PASSKEY_DELETED', 'SERVER_ERROR'
];
// Reactive form data structure
const getDefaultFormData = (): Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at' | 'config'> & { config: any } => ({
name: '',
enabled: true,
channel_type: 'webhook',
config: {}, // Will be populated based on channel_type
enabled_events: ['LOGIN_FAILURE', 'SERVER_ERROR'], // Sensible defaults
});
const formData = reactive(getDefaultFormData());
// Specific config refs for easier v-model binding
const webhookConfig = ref<WebhookConfig>({ url: '', method: 'POST', headers: {}, bodyTemplate: '' });
const emailConfig = ref<SmtpEmailConfig>({ // Use extended type
to: '',
subjectTemplate: '',
smtpHost: '',
smtpPort: 587, // Default port
smtpSecure: true, // Default to true (TLS)
smtpUser: '',
smtpPass: '',
from: ''
});
const telegramConfig = ref<TelegramConfig>({ botToken: '', chatId: '', messageTemplate: '' });
const webhookHeadersString = ref('{}'); // For textarea binding
// Watch for initialData changes (when editing)
watch(() => props.initialData, (newData) => {
if (newData) {
Object.assign(formData, newData);
// Populate specific config refs based on channel type
if (newData.channel_type === 'webhook') {
webhookConfig.value = { ...(newData.config as WebhookConfig) };
webhookHeadersString.value = JSON.stringify(webhookConfig.value.headers || {}, null, 2);
} else if (newData.channel_type === 'email') {
// Ensure all fields are present, providing defaults if missing from saved config
const savedConfig = newData.config as SmtpEmailConfig;
emailConfig.value = {
to: savedConfig.to || '',
subjectTemplate: savedConfig.subjectTemplate || '',
smtpHost: savedConfig.smtpHost || '',
smtpPort: savedConfig.smtpPort || 587,
smtpSecure: savedConfig.smtpSecure === undefined ? true : savedConfig.smtpSecure, // Default true if undefined
smtpUser: savedConfig.smtpUser || '',
smtpPass: savedConfig.smtpPass || '', // Password might not be sent back, handle appropriately
from: savedConfig.from || ''
};
} else if (newData.channel_type === 'telegram') {
telegramConfig.value = { ...(newData.config as TelegramConfig) };
}
} else {
// Reset form if initialData becomes null (e.g., switching from edit to add)
Object.assign(formData, getDefaultFormData());
webhookConfig.value = { url: '', method: 'POST', headers: {}, bodyTemplate: '' };
// Reset email config with defaults
emailConfig.value = {
to: '', subjectTemplate: '', smtpHost: '', smtpPort: 587, smtpSecure: true, smtpUser: '', smtpPass: '', from: ''
};
telegramConfig.value = { botToken: '', chatId: '', messageTemplate: '' };
webhookHeadersString.value = '{}';
}
headerError.value = null; // Reset header error on data change
testError.value = null; // Reset test error
testResult.value = null; // Reset test result
testingNotification.value = false; // Reset testing state
}, { immediate: true });
// Watch channel type change to reset specific config
watch(() => formData.channel_type, (newType, oldType) => {
if (newType !== oldType && !isEditing.value) { // Only reset if not editing or type changes during add mode
webhookConfig.value = { url: '', method: 'POST', headers: {}, bodyTemplate: '' };
emailConfig.value = {
to: '', subjectTemplate: '', smtpHost: '', smtpPort: 587, smtpSecure: true, smtpUser: '', smtpPass: '', from: ''
};
telegramConfig.value = { botToken: '', chatId: '', messageTemplate: '' };
webhookHeadersString.value = '{}';
headerError.value = null;
testError.value = null;
testResult.value = null;
testingNotification.value = false;
}
// Always reset test state when type changes
testError.value = null;
testResult.value = null;
testingNotification.value = false;
});
// Watch header string to validate JSON
watch(webhookHeadersString, (newVal) => {
if (formData.channel_type !== 'webhook') return;
try {
const parsed = JSON.parse(newVal || '{}');
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Headers must be a JSON object.');
}
webhookConfig.value.headers = parsed;
headerError.value = null;
} catch (e: any) {
headerError.value = t('settings.notifications.form.invalidJson') + `: ${e.message}`;
}
});
// Watch for changes in email config to clear previous test results
watch(emailConfig, () => {
testResult.value = null;
testError.value = null;
}, { deep: true });
const getEventDisplayName = (event: NotificationEvent): string => {
// Use i18n key, fallback to formatted name if key not found
const i18nKey = `settings.notifications.events.${event}`;
const translated = t(i18nKey);
// If translation returns the key itself, it means translation is missing
if (translated === i18nKey) {
console.warn(`Missing translation for notification event: ${i18nKey}`);
return event.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase()); // Fallback
}
return translated;
};
const handleSubmit = async () => {
formError.value = null;
if (headerError.value) return; // Don't submit if headers are invalid
// Assign the correct config based on channel type
if (formData.channel_type === 'webhook') {
formData.config = webhookConfig.value;
} else if (formData.channel_type === 'email') {
formData.config = emailConfig.value;
} else if (formData.channel_type === 'telegram') {
formData.config = telegramConfig.value;
}
let result: NotificationSetting | null = null;
if (isEditing.value && props.initialData?.id) {
result = await store.updateSetting(props.initialData.id, formData);
} else {
result = await store.addSetting(formData);
}
if (result) {
emit('save', result);
} else {
formError.value = store.error || t('common.errorOccurred');
}
};
const handleCancel = () => {
emit('cancel');
};
const handleTestNotification = async () => {
if (!props.initialData?.id || formData.channel_type !== 'email') return;
testingNotification.value = true;
testError.value = null;
testResult.value = null;
// Use the current form values for testing, even if not saved yet
const testConfig: SmtpEmailConfig = { ...emailConfig.value };
try {
const result = await store.testSetting(props.initialData.id, testConfig);
testResult.value = { success: true, message: result.message || t('settings.notifications.form.testSuccess') };
} catch (error: any) {
console.error("Test notification error:", error);
const message = error?.response?.data?.message || error.message || t('settings.notifications.form.testFailed');
testResult.value = { success: false, message: message };
// Optionally set testError if you want a separate display area for errors vs results
// testError.value = message;
} finally {
testingNotification.value = false;
// Automatically clear the result message after a few seconds
await nextTick(); // Ensure DOM is updated before setting timeout
setTimeout(() => {
testResult.value = null;
}, 5000); // Clear after 5 seconds
}
};
</script>
<style scoped>
.notification-setting-form {
padding: 1.5rem;
background-color: #fff;
border-radius: 0.3rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.channel-config h4 {
font-size: 1rem;
font-weight: bold;
margin-bottom: 1rem;
}
/* Add more specific styling if needed */
</style>
@@ -0,0 +1,135 @@
<template>
<div class="notification-settings">
<h2>{{ $t('settings.notifications.title') }}</h2>
<div v-if="store.isLoading" class="loading-indicator">
{{ $t('common.loading') }}
</div>
<div v-if="store.error" class="error-message">
{{ store.error }}
</div>
<div v-if="!store.isLoading && !store.error">
<button @click="showAddForm = true" class="btn btn-primary mb-3">
{{ $t('settings.notifications.addChannel') }}
</button>
<div v-if="settings.length === 0" class="alert alert-info">
{{ $t('settings.notifications.noChannels') }}
</div>
<ul v-else class="list-group">
<li v-for="setting in settings" :key="setting.id" class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong class="me-2">{{ setting.name }}</strong>
<span class="badge bg-secondary me-1">{{ getChannelTypeName(setting.channel_type) }}</span>
<span :class="['badge', setting.enabled ? 'bg-success' : 'bg-warning']">
{{ setting.enabled ? $t('common.enabled') : $t('common.disabled') }}
</span>
<small class="d-block text-muted">{{ getEventNames(setting.enabled_events) }}</small>
</div>
<div>
<button @click="editSetting(setting)" class="btn btn-sm btn-outline-secondary me-2">
{{ $t('common.edit') }}
</button>
<button @click="confirmDelete(setting)" class="btn btn-sm btn-outline-danger">
{{ $t('common.delete') }}
</button>
</div>
</li>
</ul>
</div>
<!-- Add/Edit Form Modal (Placeholder - will create NotificationSettingForm.vue next) -->
<div v-if="showAddForm || editingSetting" class="modal-placeholder">
<!-- Use a simple conditional rendering for the form for now -->
<!-- TODO: Consider using a proper modal component for better UX -->
<NotificationSettingForm
v-if="showAddForm || editingSetting"
:initial-data="editingSetting"
@save="handleSave"
@cancel="closeForm"
class="mt-4"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useNotificationsStore } from '../stores/notifications.store';
import { NotificationSetting, NotificationChannelType, NotificationEvent } from '../types/server.types';
import NotificationSettingForm from './NotificationSettingForm.vue'; // Import the form component
import { useI18n } from 'vue-i18n';
const store = useNotificationsStore();
const { t } = useI18n();
const showAddForm = ref(false);
const editingSetting = ref<NotificationSetting | null>(null);
const settings = computed(() => store.settings);
onMounted(() => {
store.fetchSettings();
});
const getChannelTypeName = (type: NotificationChannelType): string => {
switch (type) {
case 'webhook': return t('settings.notifications.types.webhook');
case 'email': return t('settings.notifications.types.email');
case 'telegram': return t('settings.notifications.types.telegram');
default: return type;
}
};
const getEventNames = (events: NotificationEvent[]): string => {
if (!events || events.length === 0) return t('settings.notifications.noEventsEnabled');
// TODO: Translate event names if needed
return t('settings.notifications.triggers') + ': ' + events.join(', ');
};
const editSetting = (setting: NotificationSetting) => {
editingSetting.value = { ...setting }; // Clone to avoid modifying store directly
showAddForm.value = false; // Ensure add form is hidden
};
const confirmDelete = (setting: NotificationSetting) => {
if (setting.id && confirm(t('settings.notifications.confirmDelete', { name: setting.name }))) {
store.deleteSetting(setting.id);
}
};
const closeForm = () => {
showAddForm.value = false;
editingSetting.value = null;
};
// TODO: Implement save logic when form component is ready
const handleSave = (savedSetting: NotificationSetting) => {
console.log('Setting saved:', savedSetting);
closeForm();
// The store should have updated the list automatically after add/update
// Optionally, you could force a refresh if needed: store.fetchSettings();
};
</script>
<style scoped>
.notification-settings {
padding: 1rem;
}
.loading-indicator, .error-message {
margin-top: 1rem;
}
.error-message {
color: var(--bs-danger);
}
.modal-placeholder {
margin-top: 2rem;
padding: 1rem;
border: 1px dashed #ccc;
background-color: #f8f9fa;
}
</style>
@@ -37,6 +37,11 @@ export function useSftpActions(currentPathRef: Ref<string>) {
const isLoading = ref<boolean>(false);
const error = ref<string | null>(null);
// Function to clear the error state
const clearSftpError = () => {
error.value = null;
};
// --- Action Methods ---
const loadDirectory = (path: string) => {
@@ -227,9 +232,9 @@ export function useSftpActions(currentPathRef: Ref<string>) {
const onSftpReaddirError = (payload: string, message: WebSocketMessage) => {
if (message.path === currentPathRef.value) {
console.error(`[useSftpActions] Error loading directory ${message.path}:`, payload);
error.value = payload;
error.value = payload; // Set the error message
isLoading.value = false;
fileList.value = []; // Clear list on error
// Do NOT clear fileList.value here, keep the previous list visible
}
};
@@ -316,5 +321,6 @@ export function useSftpActions(currentPathRef: Ref<string>) {
readFile, // Expose if needed by editor composable
writeFile, // Expose if needed by editor composable
joinPath, // Expose helper if needed externally
clearSftpError, // Expose the clear error function
};
}
+127 -2
View File
@@ -7,6 +7,8 @@
"login": "Login",
"logout": "Logout",
"tags": "Tags",
"notifications": "Notifications",
"auditLogs": "Audit Logs",
"settings": "Settings"
},
"login": {
@@ -240,7 +242,8 @@
"loadingFile": "Loading file...",
"saving": "Saving",
"saveSuccess": "Save successful",
"saveError": "Save error"
"saveError": "Save error",
"editPathTooltip": "Click path to edit"
},
"tags": {
"title": "Tag Management",
@@ -358,10 +361,132 @@
"success": {
"registered": "Passkey registered successfully!"
}
},
"notifications": {
"title": "Notification Settings",
"addChannel": "Add Notification Channel",
"noChannels": "No notification channels configured yet.",
"triggers": "Triggers",
"noEventsEnabled": "No events enabled",
"confirmDelete": "Are you sure you want to delete the notification channel \"{name}\"? This cannot be undone.",
"types": {
"webhook": "Webhook",
"email": "Email",
"telegram": "Telegram"
},
"form": {
"addTitle": "Add Notification Channel",
"editTitle": "Edit Notification Channel",
"name": "Channel Name:",
"channelType": "Channel Type:",
"channelTypeEditNote": "Channel type cannot be changed after creation.",
"webhookMethod": "HTTP Method:",
"webhookHeaders": "Custom Headers",
"webhookBodyTemplate": "Body Template (Optional)",
"webhookBodyPlaceholder": "Default: JSON payload. Use {{event}}, {{timestamp}}, {{details}}.",
"emailTo": "Recipient Email(s):",
"emailToHelp": "Comma-separated list.",
"emailSubjectTemplate": "Subject Template (Optional)",
"emailSubjectPlaceholder": "Default: Notification: {{event}}",
"smtpHost": "SMTP Host:",
"smtpPort": "SMTP Port:",
"smtpSecure": "Use TLS/SSL",
"smtpUser": "SMTP Username:",
"smtpPass": "SMTP Password:",
"smtpFrom": "Sender Email:",
"smtpFromHelp": "Email address used in the 'From' field.",
"testButton": "Test Notification",
"testSuccess": "Test email sent successfully!",
"testFailed": "Test email failed",
"saveToTest": "Save the settings before testing.",
"telegramToken": "Bot Token:",
"telegramTokenHelp": "Store securely. Consider environment variables.",
"telegramChatId": "Chat ID:",
"telegramMessageTemplate": "Message Template (Optional)",
"telegramMessagePlaceholder": "Default: Markdown format. Use {{event}}, {{timestamp}}, {{details}}.",
"enabledEvents": "Enabled Events:",
"templateHelp": "Placeholders: {{event}}, {{timestamp}}, {{details}} (JSON string)",
"invalidJson": "Invalid JSON"
},
"events": {
"LOGIN_SUCCESS": "Login Success",
"LOGIN_FAILURE": "Login Failure",
"CONNECTION_ADDED": "Connection Added",
"CONNECTION_UPDATED": "Connection Updated",
"CONNECTION_DELETED": "Connection Deleted",
"SETTINGS_UPDATED": "Settings Updated",
"PROXY_ADDED": "Proxy Added",
"PROXY_UPDATED": "Proxy Updated",
"PROXY_DELETED": "Proxy Deleted",
"TAG_ADDED": "Tag Added",
"TAG_UPDATED": "Tag Updated",
"TAG_DELETED": "Tag Deleted",
"API_KEY_ADDED": "API Key Added",
"API_KEY_DELETED": "API Key Deleted",
"PASSKEY_ADDED": "Passkey Added",
"PASSKEY_DELETED": "Passkey Deleted",
"SERVER_ERROR": "Server Error"
}
}
},
"common": {
"loading": "Loading...",
"cancel": "Cancel"
"cancel": "Cancel",
"save": "Save",
"saving": "Saving...",
"testing": "Testing...",
"edit": "Edit",
"delete": "Delete",
"enabled": "Enabled",
"disabled": "Disabled",
"settings": "Settings",
"errorOccurred": "An error occurred.",
"dismiss": "Dismiss"
},
"auditLog": {
"title": "Audit Logs",
"noLogs": "No audit logs found.",
"table": {
"timestamp": "Timestamp",
"actionType": "Action Type",
"details": "Details"
},
"paginationInfo": "Page {currentPage} of {totalPages} ({totalLogs} total logs)",
"actions": {
"LOGIN_SUCCESS": "Login Success",
"LOGIN_FAILURE": "Login Failure",
"LOGOUT": "Logout",
"PASSWORD_CHANGED": "Password Changed",
"2FA_ENABLED": "2FA Enabled",
"2FA_DISABLED": "2FA Disabled",
"PASSKEY_REGISTERED": "Passkey Registered",
"PASSKEY_DELETED": "Passkey Deleted",
"CONNECTION_CREATED": "Connection Created",
"CONNECTION_UPDATED": "Connection Updated",
"CONNECTION_DELETED": "Connection Deleted",
"CONNECTION_TESTED": "Connection Tested",
"CONNECTIONS_IMPORTED": "Connections Imported",
"CONNECTIONS_EXPORTED": "Connections Exported",
"PROXY_CREATED": "Proxy Created",
"PROXY_UPDATED": "Proxy Updated",
"PROXY_DELETED": "Proxy Deleted",
"TAG_CREATED": "Tag Created",
"TAG_UPDATED": "Tag Updated",
"TAG_DELETED": "Tag Deleted",
"SETTINGS_UPDATED": "Settings Updated",
"IP_WHITELIST_UPDATED": "IP Whitelist Updated",
"NOTIFICATION_SETTING_CREATED": "Notification Setting Created",
"NOTIFICATION_SETTING_UPDATED": "Notification Setting Updated",
"NOTIFICATION_SETTING_DELETED": "Notification Setting Deleted",
"API_KEY_CREATED": "API Key Created",
"API_KEY_DELETED": "API Key Deleted",
"SFTP_ACTION": "SFTP Action",
"SSH_CONNECT_SUCCESS": "SSH Connection Successful",
"SSH_CONNECT_FAILURE": "SSH Connection Failed",
"SSH_SHELL_FAILURE": "SSH Shell Open Failed",
"SERVER_STARTED": "Server Started",
"SERVER_ERROR": "Server Error",
"DATABASE_MIGRATION": "Database Migration"
}
}
}
+129 -3
View File
@@ -7,6 +7,8 @@
"login": "登录",
"logout": "登出",
"tags": "标签管理",
"notifications": "通知管理",
"auditLogs": "审计日志",
"settings": "设置"
},
"login": {
@@ -17,7 +19,8 @@
"loggingIn": "正在登录...",
"error": "登录失败,请检查用户名或密码。",
"twoFactorPrompt": "请输入两步验证码:",
"verifyButton": "验证"
"verifyButton": "验证",
"rememberMe": "记住我 (7 天)"
},
"connections": {
"title": "连接管理",
@@ -243,7 +246,8 @@
"loadingFile": "正在加载文件...",
"saving": "正在保存",
"saveSuccess": "保存成功",
"saveError": "保存出错"
"saveError": "保存出错",
"editPathTooltip": "点击路径进行编辑"
},
"tags": {
"title": "标签管理",
@@ -361,10 +365,132 @@
"success": {
"registered": "Passkey 注册成功!"
}
},
"notifications": {
"title": "通知设置",
"addChannel": "添加通知渠道",
"noChannels": "尚未配置任何通知渠道。",
"triggers": "触发事件",
"noEventsEnabled": "未启用任何事件",
"confirmDelete": "确定要删除通知渠道 \"{name}\" 吗?此操作不可撤销。",
"types": {
"webhook": "Webhook",
"email": "邮件",
"telegram": "Telegram"
},
"form": {
"addTitle": "添加通知渠道",
"editTitle": "编辑通知渠道",
"name": "渠道名称:",
"channelType": "渠道类型:",
"channelTypeEditNote": "创建后无法修改渠道类型。",
"webhookMethod": "HTTP 方法:",
"webhookHeaders": "自定义 Headers",
"webhookBodyTemplate": "请求体模板 (可选)",
"webhookBodyPlaceholder": "默认: JSON 格式负载。可使用 {{event}}, {{timestamp}}, {{details}}。",
"emailTo": "收件人邮箱:",
"emailToHelp": "多个邮箱用逗号分隔。",
"emailSubjectTemplate": "邮件主题模板 (可选)",
"emailSubjectPlaceholder": "默认: 通知: {{event}}",
"smtpHost": "SMTP 主机:",
"smtpPort": "SMTP 端口:",
"smtpSecure": "使用 TLS/SSL",
"smtpUser": "SMTP 用户名:",
"smtpPass": "SMTP 密码:",
"smtpFrom": "发件人邮箱:",
"smtpFromHelp": "用于邮件 'From' 字段的地址。",
"testButton": "测试通知",
"testSuccess": "测试邮件发送成功!",
"testFailed": "测试邮件发送失败",
"saveToTest": "请先保存设置再进行测试。",
"telegramToken": "机器人 Token:",
"telegramTokenHelp": "请安全存储。建议使用环境变量。",
"telegramChatId": "聊天 ID:",
"telegramMessageTemplate": "消息模板 (可选)",
"telegramMessagePlaceholder": "默认: Markdown 格式。可使用 {{event}}, {{timestamp}}, {{details}}。",
"enabledEvents": "启用的事件:",
"templateHelp": "可用占位符: {{event}}, {{timestamp}}, {{details}} (JSON 字符串)",
"invalidJson": "无效的 JSON 格式"
},
"events": {
"LOGIN_SUCCESS": "登录成功",
"LOGIN_FAILURE": "登录失败",
"CONNECTION_ADDED": "连接已添加",
"CONNECTION_UPDATED": "连接已更新",
"CONNECTION_DELETED": "连接已删除",
"SETTINGS_UPDATED": "设置已更新",
"PROXY_ADDED": "代理已添加",
"PROXY_UPDATED": "代理已更新",
"PROXY_DELETED": "代理已删除",
"TAG_ADDED": "标签已添加",
"TAG_UPDATED": "标签已更新",
"TAG_DELETED": "标签已删除",
"API_KEY_ADDED": "API 密钥已添加",
"API_KEY_DELETED": "API 密钥已删除",
"PASSKEY_ADDED": "Passkey 已添加",
"PASSKEY_DELETED": "Passkey 已删除",
"SERVER_ERROR": "服务器错误"
}
}
},
"common": {
"loading": "加载中...",
"cancel": "取消"
"cancel": "取消",
"save": "保存",
"saving": "保存中...",
"testing": "测试中...",
"edit": "编辑",
"delete": "删除",
"enabled": "已启用",
"disabled": "已禁用",
"settings": "设置",
"errorOccurred": "发生错误。",
"dismiss": "关闭"
},
"auditLog": {
"title": "审计日志",
"noLogs": "未找到审计日志记录。",
"table": {
"timestamp": "时间戳",
"actionType": "操作类型",
"details": "详细信息"
},
"paginationInfo": "第 {currentPage} 页 / 共 {totalPages} 页 (总计 {totalLogs} 条记录)",
"actions": {
"LOGIN_SUCCESS": "登录成功",
"LOGIN_FAILURE": "登录失败",
"LOGOUT": "登出",
"PASSWORD_CHANGED": "密码已修改",
"2FA_ENABLED": "两步验证已启用",
"2FA_DISABLED": "两步验证已禁用",
"PASSKEY_REGISTERED": "Passkey 已注册",
"PASSKEY_DELETED": "Passkey 已删除",
"CONNECTION_CREATED": "连接已创建",
"CONNECTION_UPDATED": "连接已更新",
"CONNECTION_DELETED": "连接已删除",
"CONNECTION_TESTED": "连接已测试",
"CONNECTIONS_IMPORTED": "连接已导入",
"CONNECTIONS_EXPORTED": "连接已导出",
"PROXY_CREATED": "代理已创建",
"PROXY_UPDATED": "代理已更新",
"PROXY_DELETED": "代理已删除",
"TAG_CREATED": "标签已创建",
"TAG_UPDATED": "标签已更新",
"TAG_DELETED": "标签已删除",
"SETTINGS_UPDATED": "设置已更新",
"IP_WHITELIST_UPDATED": "IP 白名单已更新",
"NOTIFICATION_SETTING_CREATED": "通知设置已创建",
"NOTIFICATION_SETTING_UPDATED": "通知设置已更新",
"NOTIFICATION_SETTING_DELETED": "通知设置已删除",
"API_KEY_CREATED": "API 密钥已创建",
"API_KEY_DELETED": "API 密钥已删除",
"SFTP_ACTION": "SFTP 操作",
"SSH_CONNECT_SUCCESS": "SSH 连接成功",
"SSH_CONNECT_FAILURE": "SSH 连接失败",
"SSH_SHELL_FAILURE": "SSH Shell 打开失败",
"SERVER_STARTED": "服务器已启动",
"SERVER_ERROR": "服务器错误",
"DATABASE_MIGRATION": "数据库迁移"
}
}
}
+12
View File
@@ -47,6 +47,18 @@ const routes: Array<RouteRecordRaw> = [
name: 'Settings',
component: () => import('../views/SettingsView.vue')
},
// 新增:通知管理页面
{
path: '/notifications',
name: 'Notifications',
component: () => import('../views/NotificationsView.vue')
},
// 新增:审计日志页面
{
path: '/audit-logs',
name: 'AuditLogs',
component: () => import('../views/AuditLogView.vue')
},
// 其他路由...
];
@@ -0,0 +1,58 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import axios from 'axios';
import { AuditLogEntry, AuditLogApiResponse, AuditLogActionType } from '../types/server.types';
export const useAuditLogStore = defineStore('auditLog', () => {
const logs = ref<AuditLogEntry[]>([]);
const totalLogs = ref(0);
const isLoading = ref(false);
const error = ref<string | null>(null);
const currentPage = ref(1);
const logsPerPage = ref(50); // Default page size
const fetchLogs = async (page: number = 1, filters: { actionType?: AuditLogActionType, startDate?: number, endDate?: number } = {}) => {
isLoading.value = true;
error.value = null;
currentPage.value = page;
const offset = (page - 1) * logsPerPage.value;
try {
const params: Record<string, any> = {
limit: logsPerPage.value,
offset: offset,
...filters // Spread filter parameters
};
// Remove undefined filter values
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
const response = await axios.get<AuditLogApiResponse>('/api/v1/audit-logs', { params });
logs.value = response.data.logs;
totalLogs.value = response.data.total;
} catch (err: any) {
console.error('Error fetching audit logs:', err);
error.value = err.response?.data?.message || '获取审计日志失败';
logs.value = [];
totalLogs.value = 0;
} finally {
isLoading.value = false;
}
};
// Function to change page size and refetch
const setLogsPerPage = (size: number) => {
logsPerPage.value = size;
fetchLogs(1); // Reset to first page when size changes
};
return {
logs,
totalLogs,
isLoading,
error,
currentPage,
logsPerPage,
fetchLogs,
setLogsPerPage,
};
});
+67 -3
View File
@@ -9,6 +9,13 @@ interface UserInfo {
isTwoFactorEnabled?: boolean; // 后端 /status 接口会返回这个
}
// 新增:登录请求的载荷接口
interface LoginPayload {
username: string;
password: string;
rememberMe?: boolean; // 可选的“记住我”标志
}
// Auth Store State 接口
interface AuthState {
isAuthenticated: boolean;
@@ -16,6 +23,11 @@ interface AuthState {
isLoading: boolean;
error: string | null;
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA
// 新增:存储 IP 黑名单数据
ipBlacklist: {
entries: any[]; // TODO: Define a proper type for blacklist entries
total: number;
};
}
export const useAuthStore = defineStore('auth', {
@@ -25,20 +37,22 @@ export const useAuthStore = defineStore('auth', {
isLoading: false,
error: null,
loginRequires2FA: false, // 初始为不需要
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
}),
getters: {
// 可以添加一些 getter,例如获取用户名
loggedInUser: (state) => state.user?.username,
},
actions: {
// 登录 Action
async login(credentials: { username: string; password: string }) {
// 登录 Action - 更新为接受 LoginPayload
async login(payload: LoginPayload) {
this.isLoading = true;
this.error = null;
this.loginRequires2FA = false; // 重置 2FA 状态
try {
// 后端可能返回 user 或 requiresTwoFactor
const response = await axios.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/api/v1/auth/login', credentials);
// 将完整的 payload (包含 rememberMe) 发送给后端
const response = await axios.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/api/v1/auth/login', payload);
if (response.data.requiresTwoFactor) {
// 需要 2FA 验证
@@ -198,6 +212,56 @@ export const useAuthStore = defineStore('auth', {
this.isLoading = false;
}
},
// --- IP 黑名单管理 Actions ---
/**
* 获取 IP 黑名单列表
* @param limit 每页数量
* @param offset 偏移量
*/
async fetchIpBlacklist(limit: number = 50, offset: number = 0) {
this.isLoading = true;
this.error = null;
try {
const response = await axios.get('/api/v1/settings/ip-blacklist', {
params: { limit, offset }
});
// 注意:这里需要将获取到的数据存储在 state 中,
// 但当前 AuthState 没有定义相关字段。
// 暂时只返回数据,需要在 state 中添加 ipBlacklist 字段。
console.log('获取 IP 黑名单成功:', response.data);
return response.data; // { entries: [], total: number }
} catch (err: any) {
console.error('获取 IP 黑名单失败:', err);
this.error = err.response?.data?.message || err.message || '获取 IP 黑名单时发生未知错误。';
// 确保抛出 Error 时提供字符串消息
throw new Error(this.error ?? '获取 IP 黑名单时发生未知错误。');
} finally {
this.isLoading = false;
}
},
/**
* 从 IP 黑名单中删除一个 IP
* @param ip 要删除的 IP 地址
*/
async deleteIpFromBlacklist(ip: string) {
this.isLoading = true;
this.error = null;
try {
await axios.delete(`/api/v1/settings/ip-blacklist/${encodeURIComponent(ip)}`);
console.log(`IP ${ip} 已从黑名单删除`);
// 成功后需要重新获取列表或从本地 state 中移除
return true;
} catch (err: any) {
console.error(`删除 IP ${ip} 失败:`, err);
this.error = err.response?.data?.message || err.message || '删除 IP 时发生未知错误。';
// 确保抛出 Error 时提供字符串消息
throw new Error(this.error ?? '删除 IP 时发生未知错误。');
} finally {
this.isLoading = false;
}
},
},
persist: true, // 使用默认持久化配置 (localStorage, 持久化所有 state)
});
@@ -0,0 +1,116 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import axios from 'axios'; // Assuming axios is globally available or installed
import { NotificationSetting, NotificationSettingData } from '../types/server.types';
export const useNotificationsStore = defineStore('notifications', () => {
const settings = ref<NotificationSetting[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
const fetchSettings = async () => {
isLoading.value = true;
error.value = null;
try {
const response = await axios.get<NotificationSetting[]>('/api/v1/notifications');
settings.value = response.data;
} catch (err: any) {
console.error('Error fetching notification settings:', err);
error.value = err.response?.data?.message || '获取通知设置失败';
settings.value = []; // Clear settings on error
} finally {
isLoading.value = false;
}
};
const addSetting = async (settingData: NotificationSettingData): Promise<NotificationSetting | null> => {
isLoading.value = true;
error.value = null;
try {
const response = await axios.post<NotificationSetting>('/api/v1/notifications', settingData);
settings.value.push(response.data);
return response.data;
} catch (err: any) {
console.error('Error adding notification setting:', err);
error.value = err.response?.data?.message || '添加通知设置失败';
return null;
} finally {
isLoading.value = false;
}
};
const updateSetting = async (id: number, settingData: Partial<NotificationSettingData>): Promise<NotificationSetting | null> => {
isLoading.value = true;
error.value = null;
try {
const response = await axios.put<NotificationSetting>(`/api/v1/notifications/${id}`, settingData);
const index = settings.value.findIndex(s => s.id === id);
if (index !== -1) {
settings.value[index] = response.data;
} else {
// If not found locally, maybe fetch again or just add it
settings.value.push(response.data);
}
return response.data;
} catch (err: any) {
console.error(`Error updating notification setting ${id}:`, err);
error.value = err.response?.data?.message || '更新通知设置失败';
return null;
} finally {
isLoading.value = false;
}
};
const deleteSetting = async (id: number): Promise<boolean> => {
isLoading.value = true;
error.value = null;
try {
await axios.delete(`/api/v1/notifications/${id}`);
settings.value = settings.value.filter(s => s.id !== id);
return true;
} catch (err: any) {
console.error(`Error deleting notification setting ${id}:`, err);
error.value = err.response?.data?.message || '删除通知设置失败';
return false;
} finally {
isLoading.value = false;
}
};
const testSetting = async (id: number, config: any): Promise<{ success: boolean; message: string }> => {
// Note: We don't set isLoading here as it might interfere with the main form submission state.
// The component handles its own 'testingNotification' state.
error.value = null; // Clear previous general errors
try {
// Send the config to test in the request body
const response = await axios.post<{ message: string }>(`/api/v1/notifications/${id}/test`, { config });
return { success: true, message: response.data.message || '测试成功' };
} catch (err: any) {
console.error(`Error testing notification setting ${id}:`, err);
// Don't set the main 'error' ref here, let the component handle test-specific errors/results.
// Throw the error so the component's catch block can handle it.
throw err; // Re-throw the error to be caught in the component
}
// No finally block needed here as loading state is managed in the component
};
// Computed property to get settings by type (example)
const webhookSettings = computed(() => settings.value.filter(s => s.channel_type === 'webhook'));
const emailSettings = computed(() => settings.value.filter(s => s.channel_type === 'email'));
const telegramSettings = computed(() => settings.value.filter(s => s.channel_type === 'telegram'));
return {
settings,
isLoading,
error,
fetchSettings,
addSetting,
updateSetting,
deleteSetting,
testSetting, // Add the new function here
webhookSettings,
emailSettings,
telegramSettings,
};
});
@@ -13,3 +13,93 @@ export interface ServerStatus {
}
// 可以根据需要添加其他与服务器或连接状态相关的类型
// --- Notification Settings Types (Mirrors backend/src/types/notification.types.ts) ---
export type NotificationChannelType = 'webhook' | 'email' | 'telegram';
export type NotificationEvent =
| 'LOGIN_SUCCESS'
| 'LOGIN_FAILURE'
| 'CONNECTION_ADDED'
| 'CONNECTION_UPDATED'
| 'CONNECTION_DELETED'
| 'SETTINGS_UPDATED'
| 'PROXY_ADDED'
| 'PROXY_UPDATED'
| 'PROXY_DELETED'
| 'TAG_ADDED'
| 'TAG_UPDATED'
| 'TAG_DELETED'
| 'API_KEY_ADDED'
| 'API_KEY_DELETED'
| 'PASSKEY_ADDED'
| 'PASSKEY_DELETED'
| 'SERVER_ERROR';
export interface WebhookConfig {
url: string;
method?: 'POST' | 'GET' | 'PUT';
headers?: Record<string, string>;
bodyTemplate?: string;
}
export interface EmailConfig {
to: string;
subjectTemplate?: string;
// SMTP settings are global on the backend
}
export interface TelegramConfig {
botToken: string; // Consider masking this in the UI
chatId: string;
messageTemplate?: string;
}
export type NotificationChannelConfig = WebhookConfig | EmailConfig | TelegramConfig;
export interface NotificationSetting {
id?: number;
channel_type: NotificationChannelType;
name: string;
enabled: boolean;
config: NotificationChannelConfig;
enabled_events: NotificationEvent[];
created_at?: number | string; // Represented as string or number (timestamp)
updated_at?: number | string;
}
// Helper type for creating/updating settings, omitting generated fields
export type NotificationSettingData = Omit<NotificationSetting, 'id' | 'created_at' | 'updated_at'>;
// --- Audit Log Types (Mirrors backend/src/types/audit.types.ts) ---
// Keep action types aligned with backend for potential filtering
export type AuditLogActionType =
| 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGOUT' | 'PASSWORD_CHANGED'
| '2FA_ENABLED' | '2FA_DISABLED' | 'PASSKEY_REGISTERED' | 'PASSKEY_DELETED'
| 'CONNECTION_CREATED' | 'CONNECTION_UPDATED' | 'CONNECTION_DELETED' | 'CONNECTION_TESTED'
| 'CONNECTIONS_IMPORTED' | 'CONNECTIONS_EXPORTED'
| 'PROXY_CREATED' | 'PROXY_UPDATED' | 'PROXY_DELETED'
| 'TAG_CREATED' | 'TAG_UPDATED' | 'TAG_DELETED'
| 'SETTINGS_UPDATED' | 'IP_WHITELIST_UPDATED'
| 'NOTIFICATION_SETTING_CREATED' | 'NOTIFICATION_SETTING_UPDATED' | 'NOTIFICATION_SETTING_DELETED'
| 'API_KEY_CREATED' | 'API_KEY_DELETED'
| 'SFTP_ACTION'
| 'SERVER_STARTED' | 'SERVER_ERROR' | 'DATABASE_MIGRATION';
// Structure for a single log entry received from the API
export interface AuditLogEntry {
id: number;
timestamp: number; // Unix timestamp (seconds)
action_type: AuditLogActionType;
details: Record<string, any> | { raw: string; parseError: boolean } | null; // Parsed JSON or raw string with error flag
}
// Structure for the API response when fetching logs
export interface AuditLogApiResponse {
logs: AuditLogEntry[];
total: number;
limit: number;
offset: number;
}
@@ -0,0 +1,168 @@
<template>
<div class="audit-log-view">
<h1>{{ $t('auditLog.title') }}</h1>
<!-- TODO: Add filtering options (Action Type, Date Range) -->
<div v-if="store.isLoading" class="loading-indicator">
{{ $t('common.loading') }}
</div>
<div v-if="store.error" class="error-message">
{{ store.error }}
</div>
<div v-if="!store.isLoading && !store.error">
<div v-if="logs.length === 0" class="alert alert-info">
{{ $t('auditLog.noLogs') }}
</div>
<div v-else>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>{{ $t('auditLog.table.timestamp') }}</th>
<th>{{ $t('auditLog.table.actionType') }}</th>
<th>{{ $t('auditLog.table.details') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.id">
<td>{{ formatTimestamp(log.timestamp) }}</td>
<td>{{ translateActionType(log.action_type) }}</td>
<td>
<pre v-if="log.details">{{ formatDetails(log.details) }}</pre>
<span v-else>-</span>
</td>
</tr>
</tbody>
</table>
<!-- Pagination Controls -->
<nav aria-label="Audit Log Pagination" v-if="totalPages > 1">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<a class="page-link" href="#" @click.prevent="changePage(currentPage - 1)">&laquo;</a>
</li>
<li v-for="page in paginationRange" :key="page" class="page-item" :class="{ active: page === currentPage, 'disabled': page === '...' }">
<a v-if="page !== '...'" class="page-link" href="#" @click.prevent="changePage(page as number)">{{ page }}</a>
<span v-else class="page-link">...</span>
</li>
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
<a class="page-link" href="#" @click.prevent="changePage(currentPage + 1)">&raquo;</a>
</li>
</ul>
</nav>
<div class="text-center text-muted mt-2">
{{ $t('auditLog.paginationInfo', { currentPage, totalPages, totalLogs }) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useAuditLogStore } from '../stores/audit.store';
import { AuditLogEntry, AuditLogActionType } from '../types/server.types';
import { useI18n } from 'vue-i18n';
const store = useAuditLogStore();
const { t } = useI18n();
const logs = computed(() => store.logs);
const totalLogs = computed(() => store.totalLogs);
const currentPage = computed(() => store.currentPage);
const logsPerPage = computed(() => store.logsPerPage);
const totalPages = computed(() => Math.ceil(totalLogs.value / logsPerPage.value));
onMounted(() => {
store.fetchLogs();
});
const formatTimestamp = (timestamp: number): string => {
// Convert seconds to milliseconds for Date constructor
return new Date(timestamp * 1000).toLocaleString();
};
const translateActionType = (actionType: AuditLogActionType): string => {
// Attempt to translate using a convention like auditLog.actions.ACTION_TYPE
const key = `auditLog.actions.${actionType}`;
const translated = t(key);
// If translation is missing, return the original type
return translated === key ? actionType : translated;
};
const formatDetails = (details: AuditLogEntry['details']): string => {
if (!details) return '';
if (typeof details === 'object' && details !== null) {
if ('raw' in details && details.parseError) {
return `[Parse Error] Raw: ${details.raw}`;
}
return JSON.stringify(details, null, 2); // Pretty print JSON
}
return String(details); // Should ideally not happen if backend sends JSON string
};
const changePage = (page: number) => {
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
store.fetchLogs(page);
}
};
// Simple pagination range logic (can be improved for many pages)
const paginationRange = computed(() => {
const range: (number | string)[] = [];
const delta = 2; // Number of pages around current page
const left = currentPage.value - delta;
const right = currentPage.value + delta + 1;
let l: number | null = null; // Keep track of the last number added
for (let i = 1; i <= totalPages.value; i++) {
if (i === 1 || i === totalPages.value || (i >= left && i < right)) {
range.push(i);
}
}
const result: (number | string)[] = [];
for (const pageNum of range) {
// Ensure pageNum is treated as number for comparison/arithmetic
const currentNum = pageNum as number;
if (l !== null) {
// Calculate difference explicitly as numbers
if (currentNum - l === 2) {
result.push(l + 1);
} else if (currentNum - l > 1) { // Check if difference is greater than 1
result.push('...');
}
}
result.push(currentNum);
l = currentNum; // Store the current number
}
return result;
});
</script>
<style scoped>
.audit-log-view {
padding: 20px;
}
.loading-indicator, .error-message {
margin-top: 1rem;
text-align: center;
}
.error-message {
color: var(--bs-danger);
}
pre {
white-space: pre-wrap; /* Allow wrapping */
word-break: break-all; /* Break long strings */
background-color: #f8f9fa;
padding: 0.5rem;
border-radius: 0.25rem;
font-size: 0.9em;
}
.pagination {
margin-top: 1.5rem;
}
</style>
+28 -2
View File
@@ -15,6 +15,7 @@ const credentials = reactive({
password: '',
});
const twoFactorToken = ref(''); // 2FA
const rememberMe = ref(false); // false
// 2FA
const handleSubmit = async () => {
@@ -22,8 +23,8 @@ const handleSubmit = async () => {
// 2FA 2FA action
await authStore.verifyLogin2FA(twoFactorToken.value);
} else {
// action
await authStore.login(credentials);
// action rememberMe
await authStore.login({ ...credentials, rememberMe: rememberMe.value });
}
// store action
// error
@@ -45,6 +46,11 @@ const handleSubmit = async () => {
<label for="password">{{ t('login.password') }}:</label>
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading" />
</div>
<!-- 新增记住我复选框 -->
<div class="form-group form-group-checkbox">
<input type="checkbox" id="rememberMe" v-model="rememberMe" :disabled="isLoading" />
<label for="rememberMe">{{ t('login.rememberMe') }}</label>
</div>
</div>
<!-- 2FA 验证码输入 -->
@@ -93,6 +99,26 @@ h2 {
margin-bottom: 1.5rem;
}
/* Specific style for checkbox group */
.form-group-checkbox {
display: flex;
align-items: center;
margin-bottom: 1.5rem; /* Keep consistent margin */
}
.form-group-checkbox input[type="checkbox"] {
width: auto; /* Override default width */
margin-right: 0.5rem; /* Space between checkbox and label */
accent-color: #007bff; /* Optional: Style the checkmark color */
}
.form-group-checkbox label {
margin-bottom: 0; /* Remove bottom margin for inline label */
font-weight: normal; /* Optional: Make label less bold */
cursor: pointer; /* Indicate it's clickable */
}
label {
display: block;
margin-bottom: 0.5rem;
@@ -0,0 +1,17 @@
<template>
<div class="notifications-view">
<!-- <h1>{{ $t('nav.notifications') }}</h1> --> <!-- Add nav.notifications to i18n later -->
<h1>通知管理</h1> <!-- Temporary title -->
<NotificationSettings />
</div>
</template>
<script setup lang="ts">
import NotificationSettings from '../components/NotificationSettings.vue';
</script>
<style scoped>
.notifications-view {
padding: 20px;
}
</style>
+272 -1
View File
@@ -99,15 +99,86 @@
<!-- 其他设置项可以在这里添加 -->
<hr>
<!-- IP 黑名单管理 -->
<div class="settings-section">
<h2>IP 黑名单管理</h2>
<p>配置登录失败次数限制和自动封禁时长本地地址 (127.0.0.1, ::1) 不会被封禁</p>
<!-- 黑名单配置表单 -->
<form @submit.prevent="handleUpdateBlacklistSettings" class="blacklist-settings-form">
<div class="form-group inline-group">
<label for="maxLoginAttempts">最大失败次数:</label>
<input type="number" id="maxLoginAttempts" v-model="blacklistSettings.maxLoginAttempts" min="1" required>
</div>
<div class="form-group inline-group">
<label for="loginBanDuration">封禁时长 ():</label>
<input type="number" id="loginBanDuration" v-model="blacklistSettings.loginBanDuration" min="1" required>
</div>
<button type="submit" :disabled="blacklistSettings.loading">{{ blacklistSettings.loading ? '保存中...' : '保存配置' }}</button>
<p v-if="blacklistSettings.message" :class="{ 'success-message': blacklistSettings.success, 'error-message': !blacklistSettings.success }">{{ blacklistSettings.message }}</p>
</form>
<hr style="margin-top: 20px; margin-bottom: 20px;">
<h3>当前已封禁的 IP 地址</h3>
<div v-if="ipBlacklist.loading" class="loading-message">正在加载黑名单...</div>
<div v-if="ipBlacklist.error" class="error-message">{{ ipBlacklist.error }}</div>
<div v-if="!ipBlacklist.loading && !ipBlacklist.error">
<table v-if="ipBlacklist.entries.length > 0" class="blacklist-table">
<thead>
<tr>
<th>IP 地址</th>
<th>失败次数</th>
<th>最后尝试时间</th>
<th>封禁截止时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in ipBlacklist.entries" :key="entry.ip">
<td>{{ entry.ip }}</td>
<td>{{ entry.attempts }}</td>
<td>{{ new Date(entry.last_attempt_at * 1000).toLocaleString() }}</td>
<td>{{ entry.blocked_until ? new Date(entry.blocked_until * 1000).toLocaleString() : 'N/A' }}</td>
<td>
<button
@click="handleDeleteIp(entry.ip)"
:disabled="blacklistDeleteLoading && blacklistToDeleteIp === entry.ip"
class="btn-danger"
>
{{ (blacklistDeleteLoading && blacklistToDeleteIp === entry.ip) ? '删除中...' : '移除' }}
</button>
</td>
</tr>
</tbody>
</table>
<p v-else>当前没有 IP 地址在黑名单中</p>
<!-- 分页控件 (如果需要) -->
<!--
<div class="pagination" v-if="ipBlacklist.total > ipBlacklist.limit">
<button @click="fetchIpBlacklist(ipBlacklist.currentPage - 1)" :disabled="ipBlacklist.currentPage <= 1">上一页</button>
<span> {{ ipBlacklist.currentPage }} / {{ Math.ceil(ipBlacklist.total / ipBlacklist.limit) }} </span>
<button @click="fetchIpBlacklist(ipBlacklist.currentPage + 1)" :disabled="ipBlacklist.currentPage * ipBlacklist.limit >= ipBlacklist.total">下一页</button>
</div>
-->
<p v-if="blacklistDeleteError" class="error-message">{{ blacklistDeleteError }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, reactive } from 'vue'; // computed reactive
import { useAuthStore } from '../stores/auth.store';
import { useI18n } from 'vue-i18n';
import axios from 'axios'; // axios
import { startRegistration } from '@simplewebauthn/browser'; // simplewebauthn
// import NotificationSettings from '../components/NotificationSettings.vue'; //
const authStore = useAuthStore();
const { t } = useI18n();
@@ -188,6 +259,29 @@ const ipWhitelistLoading = ref(false);
const ipWhitelistMessage = ref('');
const ipWhitelistSuccess = ref(false);
// --- IP ---
const ipBlacklist = reactive({
entries: [] as any[], // TODO: Define proper type
total: 0,
loading: false,
error: null as string | null,
currentPage: 1,
limit: 10, //
});
const blacklistToDeleteIp = ref<string | null>(null); // IP
const blacklistDeleteLoading = ref(false);
const blacklistDeleteError = ref<string | null>(null);
// --- ---
const blacklistSettings = reactive({
maxLoginAttempts: '5', //
loginBanDuration: '300', // ()
loading: false,
message: '',
success: false,
});
// 2FA
const isSettingUp2FA = computed(() => setupData.value !== null);
@@ -353,6 +447,102 @@ const handleUpdateIpWhitelist = async () => {
}
};
// --- IP ---
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 || '获取黑名单失败';
} finally {
ipBlacklist.loading = false;
}
};
const handleDeleteIp = async (ip: string) => {
blacklistToDeleteIp.value = ip; // IP
//
if (confirm(`确定要从黑名单中移除 IP 地址 "${ip}" 吗?`)) {
blacklistDeleteLoading.value = true;
blacklistDeleteError.value = null;
try {
await authStore.deleteIpFromBlacklist(ip);
//
await fetchIpBlacklist(ipBlacklist.currentPage);
} catch (error: any) {
blacklistDeleteError.value = error.message || '删除失败';
} finally {
blacklistDeleteLoading.value = false;
blacklistToDeleteIp.value = null; // IP
}
} else {
blacklistToDeleteIp.value = null; // IP
}
};
//
const fetchBlacklistSettings = async () => {
blacklistSettings.loading = true;
blacklistSettings.message = '';
try {
const response = await axios.get<Record<string, string>>('/api/v1/settings');
blacklistSettings.maxLoginAttempts = response.data['maxLoginAttempts'] || '5';
blacklistSettings.loginBanDuration = response.data['loginBanDuration'] || '300';
} catch (error: any) {
console.error('获取黑名单配置失败:', error);
blacklistSettings.message = '获取黑名单配置失败';
blacklistSettings.success = false;
} finally {
blacklistSettings.loading = false;
}
};
//
const handleUpdateBlacklistSettings = async () => {
blacklistSettings.loading = true;
blacklistSettings.message = '';
blacklistSettings.success = false;
try {
//
const maxAttempts = parseInt(blacklistSettings.maxLoginAttempts, 10);
const banDuration = parseInt(blacklistSettings.loginBanDuration, 10);
if (isNaN(maxAttempts) || maxAttempts <= 0) {
throw new Error('最大失败次数必须是正整数。');
}
if (isNaN(banDuration) || banDuration <= 0) {
throw new Error('封禁时长必须是正整数(秒)。');
}
await axios.put('/api/v1/settings', {
maxLoginAttempts: blacklistSettings.maxLoginAttempts,
loginBanDuration: blacklistSettings.loginBanDuration,
});
blacklistSettings.message = '黑名单配置已成功更新。';
blacklistSettings.success = true;
} catch (error: any) {
console.error('更新黑名单配置失败:', error);
blacklistSettings.message = error.message || '更新黑名单配置失败';
blacklistSettings.success = false;
} finally {
blacklistSettings.loading = false;
}
};
// onMounted fetchIpBlacklist fetchBlacklistSettings
onMounted(async () => { // 使 onMounted
await checkTwoFactorStatus(); //
await fetchIpWhitelist(); // IP
await fetchIpBlacklist(); // IP
await fetchBlacklistSettings(); //
});
</script>
<style scoped>
@@ -439,4 +629,85 @@ img {
color: red;
margin-top: 10px;
}
/* Blacklist Table Styles */
.blacklist-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.blacklist-table th,
.blacklist-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.blacklist-table th {
background-color: #f2f2f2;
font-weight: bold;
}
.blacklist-table .btn-danger {
background-color: #dc3545;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.blacklist-table .btn-danger:disabled {
background-color: #f8d7da;
cursor: not-allowed;
}
.loading-message {
margin-top: 15px;
color: #666;
}
/* Pagination Styles (Optional) */
.pagination {
margin-top: 15px;
display: flex;
justify-content: center;
align-items: center;
}
.pagination button {
margin: 0 5px;
}
.pagination span {
margin: 0 10px;
}
/* Blacklist Settings Form Styles */
.blacklist-settings-form {
margin-top: 15px;
}
.blacklist-settings-form .inline-group {
display: inline-block; /* 让 label 和 input 在一行显示 */
margin-right: 20px; /* 组之间的间距 */
margin-bottom: 10px; /* 增加底部间距 */
}
.blacklist-settings-form .inline-group label {
display: inline-block; /* 行内块 */
margin-right: 5px; /* label 和 input 之间的间距 */
width: auto; /* 覆盖默认的 block 宽度 */
margin-bottom: 0; /* 移除默认的底部间距 */
}
.blacklist-settings-form .inline-group input[type="number"] {
width: 80px; /* 设置一个合适的宽度 */
display: inline-block; /* 行内块 */
padding: 6px; /* 调整内边距 */
}
.blacklist-settings-form button {
vertical-align: bottom; /* 对齐按钮和输入框 */
}
.blacklist-settings-form p { /* 消息样式 */
margin-top: 10px;
}
</style>