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
+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>