update
This commit is contained in:
@@ -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')">×</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>
|
||||
Reference in New Issue
Block a user