diff --git a/package-lock.json b/package-lock.json index 256e091..b3a2616 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,18 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", @@ -939,6 +951,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qrcode": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", @@ -3073,6 +3095,43 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.0.0.tgz", + "integrity": "sha512-POPvwjOPR1GQvRnbikTMPEhQD+ekd186MHE6NtVxl3Lby+gPp0iq60eCqGrY6wfRnp1lejjFNu0EKs1afA322w==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-fs-backend": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.6.0.tgz", + "integrity": "sha512-3ZlhNoF9yxnM8pa8bWp5120/Ob6t4lVl1l/tbLmkml/ei3ud8IWySCHt2lrY5xWRlSU5D9IV2sm5bEbGuTqwTw==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3974,6 +4033,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -4594,6 +4662,12 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6149,7 +6223,10 @@ "express": "^5.1.0", "express-session": "^1.18.1", "https-proxy-agent": "^7.0.6", + "i18next": "^25.0.0", + "i18next-fs-backend": "^2.6.0", "multer": "^1.4.5-lts.2", + "nodemailer": "^6.10.1", "socks": "^2.8.4", "sqlite3": "^5.1.7", "ssh2": "^1.16.0", @@ -6162,6 +6239,7 @@ "@types/express": "^5.0.1", "@types/express-session": "^1.18.1", "@types/node": "^20.0.0", + "@types/nodemailer": "^6.4.17", "@types/sqlite3": "^3.1.11", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", diff --git a/packages/backend/package.json b/packages/backend/package.json index ad27db6..7caca35 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -18,7 +18,10 @@ "express": "^5.1.0", "express-session": "^1.18.1", "https-proxy-agent": "^7.0.6", + "i18next": "^25.0.0", + "i18next-fs-backend": "^2.6.0", "multer": "^1.4.5-lts.2", + "nodemailer": "^6.10.1", "socks": "^2.8.4", "sqlite3": "^5.1.7", "ssh2": "^1.16.0", @@ -31,6 +34,7 @@ "@types/express": "^5.0.1", "@types/express-session": "^1.18.1", "@types/node": "^20.0.0", + "@types/nodemailer": "^6.4.17", "@types/sqlite3": "^3.1.11", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", diff --git a/packages/backend/src/i18n.ts b/packages/backend/src/i18n.ts new file mode 100644 index 0000000..38df675 --- /dev/null +++ b/packages/backend/src/i18n.ts @@ -0,0 +1,27 @@ +import i18next from 'i18next'; +import Backend from 'i18next-fs-backend'; +import path from 'path'; + +// 定义支持的语言 +export const supportedLngs = ['en', 'zh']; +export const defaultLng = 'en'; + +i18next + .use(Backend) + .init({ + // debug: process.env.NODE_ENV === 'development', // 可选:开发模式下开启调试 + supportedLngs, + fallbackLng: defaultLng, + lng: defaultLng, // 默认语言 + ns: ['notifications'], // 命名空间,用于组织翻译 + defaultNS: 'notifications', + backend: { + // path where resources get loaded from + loadPath: path.join(__dirname, 'locales/{{lng}}/{{ns}}.json'), + }, + interpolation: { + escapeValue: false, // 不对插值进行转义,因为我们可能需要 HTML 或 Markdown + }, + }); + +export default i18next; diff --git a/packages/backend/src/locales/en/notifications.json b/packages/backend/src/locales/en/notifications.json new file mode 100644 index 0000000..476df1e --- /dev/null +++ b/packages/backend/src/locales/en/notifications.json @@ -0,0 +1,10 @@ +{ + "connection": { + "testSuccess": "Connection test successful for '{name}'!", + "testFailed": "Connection test failed for '{name}': {error}" + }, + "settings": { + "updated": "Settings updated successfully.", + "ipWhitelistUpdated": "IP Whitelist updated successfully." + } +} diff --git a/packages/backend/src/locales/zh/notifications.json b/packages/backend/src/locales/zh/notifications.json new file mode 100644 index 0000000..733edfd --- /dev/null +++ b/packages/backend/src/locales/zh/notifications.json @@ -0,0 +1,10 @@ +{ + "connection": { + "testSuccess": "连接 '{name}' 测试成功!", + "testFailed": "连接 '{name}' 测试失败: {error}" + }, + "settings": { + "updated": "设置已成功更新。", + "ipWhitelistUpdated": "IP 白名单已成功更新。" + } +} diff --git a/packages/backend/src/notifications/notification.controller.ts b/packages/backend/src/notifications/notification.controller.ts index f9570a5..f5abbe4 100644 --- a/packages/backend/src/notifications/notification.controller.ts +++ b/packages/backend/src/notifications/notification.controller.ts @@ -120,17 +120,11 @@ export class NotificationController { const originalSetting = await this.notificationService.getSettingById(id); if (!originalSetting) { res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` }); - return; + return; // Return early if setting not found } - // Currently, only email testing is implemented - if (originalSetting.channel_type !== 'email') { - res.status(400).json({ message: `当前仅支持测试邮件通知渠道` }); - return; - } - - // Call the service method to send the test email using the provided config - const result = await this.notificationService.testEmailSetting(config); + // Call the generic testSetting method from the service, passing the channel type + const result = await this.notificationService.testSetting(originalSetting.channel_type, config); if (result.success) { // 记录审计日志 (可选,根据需要决定是否记录测试操作) @@ -147,4 +141,35 @@ export class NotificationController { res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message }); } }; + + // POST /api/v1/notifications/test-unsaved + testUnsavedSetting = async (req: Request, res: Response): Promise => { + const { channel_type, config } = req.body; + + if (!channel_type || !config) { + res.status(400).json({ message: '缺少必要的测试信息 (channel_type, config)' }); + return; + } + + // Basic validation for channel type + if (!['webhook', 'email', 'telegram'].includes(channel_type)) { + res.status(400).json({ message: '无效的渠道类型' }); + return; + } + + try { + // Call the generic testSetting method directly with provided type and config + const result = await this.notificationService.testSetting(channel_type, config); + + if (result.success) { + res.status(200).json({ message: result.message }); + } else { + // Return 500 for test failure to indicate an issue with the config/sending + res.status(500).json({ message: result.message }); + } + } catch (error: any) { + console.error(`Error testing unsaved notification setting:`, error); + res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message }); + } + }; } diff --git a/packages/backend/src/notifications/notification.routes.ts b/packages/backend/src/notifications/notification.routes.ts index 3c621fe..a4b86d7 100644 --- a/packages/backend/src/notifications/notification.routes.ts +++ b/packages/backend/src/notifications/notification.routes.ts @@ -14,7 +14,10 @@ router.post('/', notificationController.create); router.put('/:id', notificationController.update); router.delete('/:id', notificationController.delete); -// Route for testing a notification setting (currently only email) +// Route for testing a saved notification setting router.post('/:id/test', notificationController.testSetting); +// Route for testing an unsaved notification setting configuration +router.post('/test-unsaved', notificationController.testUnsavedSetting); + export default router; diff --git a/packages/backend/src/services/ip-blacklist.service.ts b/packages/backend/src/services/ip-blacklist.service.ts index 06d2210..39968cd 100644 --- a/packages/backend/src/services/ip-blacklist.service.ts +++ b/packages/backend/src/services/ip-blacklist.service.ts @@ -1,8 +1,10 @@ import { getDb } from '../database'; -import { settingsService } from './settings.service'; // 用于获取配置 -import * as sqlite3 from 'sqlite3'; // 导入 sqlite3 类型 +import { settingsService } from './settings.service'; +import { NotificationService } from './notification.service'; // 导入 NotificationService +import * as sqlite3 from 'sqlite3'; const db = getDb(); +const notificationService = new NotificationService(); // 实例化 NotificationService // 黑名单相关设置的 Key const MAX_LOGIN_ATTEMPTS_KEY = 'maxLoginAttempts'; @@ -96,11 +98,23 @@ export class IpBlacklistService { let blockedUntil = entry.blocked_until; // 检查是否达到封禁阈值 - if (newAttempts >= maxAttempts) { + if (newAttempts >= maxAttempts && !entry.blocked_until) { // 只有在之前未被封禁时才触发通知 blockedUntil = now + banDuration; console.warn(`[IP Blacklist] IP ${ip} 登录失败次数达到 ${newAttempts} 次 (阈值 ${maxAttempts}),将被封禁 ${banDuration} 秒。`); + // 触发 IP_BLACKLISTED 通知 + notificationService.sendNotification('IP_BLACKLISTED', { + ip: ip, + attempts: newAttempts, + duration: banDuration, // 封禁时长(秒) + blockedUntil: new Date(blockedUntil * 1000).toISOString() // 封禁截止时间 + }).catch(err => console.error(`[IP Blacklist] 发送 IP_BLACKLISTED 通知失败 for IP ${ip}:`, err)); + } else if (newAttempts >= maxAttempts && entry.blocked_until) { + // 如果已经达到阈值且已被封禁,可能需要更新封禁时间(如果策略是每次失败都延长) + // 当前逻辑是只在首次达到阈值时设置封禁时间,后续失败只增加次数 + console.log(`[IP Blacklist] IP ${ip} 再次登录失败,当前已处于封禁状态。`); } + await new Promise((resolve, reject) => { db.run( 'UPDATE ip_blacklist SET attempts = ?, last_attempt_at = ?, blocked_until = ? WHERE ip = ?', @@ -117,9 +131,17 @@ export class IpBlacklistService { } else { // 插入新记录 let blockedUntil: number | null = null; - if (1 >= maxAttempts) { // 首次尝试就达到阈值 (虽然不常见) + const attempts = 1; // 首次尝试 + if (attempts >= maxAttempts) { // 首次尝试就达到阈值 blockedUntil = now + banDuration; console.warn(`[IP Blacklist] IP ${ip} 首次登录失败即达到阈值 ${maxAttempts},将被封禁 ${banDuration} 秒。`); + // 触发 IP_BLACKLISTED 通知 + notificationService.sendNotification('IP_BLACKLISTED', { + ip: ip, + attempts: attempts, + duration: banDuration, + blockedUntil: new Date(blockedUntil * 1000).toISOString() + }).catch(err => console.error(`[IP Blacklist] 发送 IP_BLACKLISTED 通知失败 for IP ${ip}:`, err)); } await new Promise((resolve, reject) => { db.run( diff --git a/packages/backend/src/services/notification.service.ts b/packages/backend/src/services/notification.service.ts index c574e73..20d95d7 100644 --- a/packages/backend/src/services/notification.service.ts +++ b/packages/backend/src/services/notification.service.ts @@ -5,12 +5,15 @@ import { NotificationEvent, NotificationPayload, WebhookConfig, - EmailConfig, // Ensure EmailConfig is imported + EmailConfig, TelegramConfig, - NotificationChannelConfig + NotificationChannelConfig, + NotificationChannelType // Import the missing type } from '../types/notification.types'; import * as nodemailer from 'nodemailer'; import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter +import i18next, { defaultLng, supportedLngs } from '../i18n'; // Import i18next instance and config +import { settingsService } from './settings.service'; // Import settings service export class NotificationService { private repository: NotificationSettingsRepository; @@ -43,10 +46,27 @@ export class NotificationService { return this.repository.delete(id); } - // --- Test Notification Method --- - async testEmailSetting(config: EmailConfig): Promise<{ success: boolean; message: string }> { + // --- Test Notification Methods --- + + // Generic test method dispatcher + async testSetting(channelType: NotificationChannelType, config: NotificationChannelConfig): Promise<{ success: boolean; message: string }> { + switch (channelType) { + case 'email': + return this._testEmailSetting(config as EmailConfig); + case 'webhook': + return this._testWebhookSetting(config as WebhookConfig); + case 'telegram': + return this._testTelegramSetting(config as TelegramConfig); + default: + console.warn(`[Notification Test] Unsupported channel type for testing: ${channelType}`); + return { success: false, message: `不支持测试此渠道类型 (${channelType})` }; + } + } + + // Specific test method for Email + private async _testEmailSetting(config: EmailConfig): Promise<{ success: boolean; message: string }> { if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) { - return { success: false, message: '测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 服务器, 端口, 发件人)。' }; + return { success: false, message: '测试邮件失败:缺少必要的 SMTP 配置信息 (收件人, 主机, 端口, 发件人)。' }; } // Let TypeScript infer the options type for SMTP @@ -69,9 +89,9 @@ export class NotificationService { const mailOptions: Mail.Options = { from: config.from, to: config.to, // Use the 'to' from config for testing - subject: '星枢终端 (Nexus Terminal) 测试邮件', - text: `这是一封来自星枢终端 (Nexus Terminal) 的测试邮件。\n\n如果收到此邮件,表示您的 SMTP 配置工作正常。\n\n时间: ${new Date().toISOString()}`, - html: `

这是一封来自 星枢终端 (Nexus Terminal) 的测试邮件。

如果收到此邮件,表示您的 SMTP 配置工作正常。

时间: ${new Date().toISOString()}

`, + subject: 'Nexus Terminal Test Notification', + text: `This is a test email from Nexus Terminal.\n\nIf you received this, your SMTP configuration is working.\n\nTimestamp: ${new Date().toISOString()}`, + html: `

This is a test email from Nexus Terminal.

If you received this, your SMTP configuration is working.

Timestamp: ${new Date().toISOString()}

`, }; try { @@ -88,11 +108,107 @@ export class NotificationService { } } + // Specific test method for Webhook + private async _testWebhookSetting(config: WebhookConfig): Promise<{ success: boolean; message: string }> { + if (!config.url) { + return { success: false, message: '测试 Webhook 失败:缺少 URL。' }; + } + + // Use a valid event type for the test payload + const testPayload: NotificationPayload = { + event: 'SETTINGS_UPDATED', // Use a valid event type + timestamp: Date.now(), + details: { message: 'This is a test notification from Nexus Terminal (Webhook).' } // Add channel type + }; + + // Use the same rendering logic as actual sending + const defaultBody = JSON.stringify(testPayload, null, 2); + const requestBody = this._renderTemplate(config.bodyTemplate, testPayload, defaultBody); + + const requestConfig: AxiosRequestConfig = { + method: config.method || 'POST', + url: config.url, + headers: { + 'Content-Type': 'application/json', + ...(config.headers || {}), + }, + data: requestBody, + timeout: 15000, // Slightly longer timeout for testing + }; + + try { + console.log(`[Notification Test] Sending test Webhook to ${config.url}`); + const response = await axios(requestConfig); + console.log(`[Notification Test] Test Webhook sent successfully to ${config.url}. Status: ${response.status}`); + return { success: true, message: `测试 Webhook 发送成功 (状态码: ${response.status})。` }; + } catch (error: any) { + const errorMessage = error.response?.data?.message || error.response?.data || error.message || '未知错误'; + console.error(`[Notification Test] Error sending test Webhook to ${config.url}:`, errorMessage); + return { success: false, message: `测试 Webhook 发送失败: ${errorMessage}` }; + } + } + + // Specific test method for Telegram + private async _testTelegramSetting(config: TelegramConfig): Promise<{ success: boolean; message: string }> { + if (!config.botToken || !config.chatId) { + return { success: false, message: '测试 Telegram 失败:缺少机器人 Token 或聊天 ID。' }; + } + + // Use a valid event type for the test payload + const testPayload: NotificationPayload = { + event: 'SETTINGS_UPDATED', // Use a valid event type + timestamp: Date.now(), + details: { message: 'This is a test notification from Nexus Terminal (Telegram).' } // Add channel type + }; + + // Use the same rendering logic as actual sending + const defaultMessage = `*Nexus Terminal Test Notification*\n\nEvent: \`${testPayload.event}\`\nTimestamp: ${new Date(testPayload.timestamp).toISOString()}\nDetails: \`\`\`\n${JSON.stringify(testPayload.details, null, 2)}\n\`\`\``; + const messageText = this._renderTemplate(config.messageTemplate, testPayload, defaultMessage); + + const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`; + + try { + console.log(`[Notification Test] Sending test Telegram message to chat ID ${config.chatId}`); + const response = await axios.post(telegramApiUrl, { + chat_id: config.chatId, + text: messageText, + parse_mode: 'Markdown', + }, { timeout: 15000 }); // Slightly longer timeout for testing + + if (response.data?.ok) { + console.log(`[Notification Test] Test Telegram message sent successfully.`); + return { success: true, message: '测试 Telegram 消息发送成功!' }; + } else { + console.error(`[Notification Test] Telegram API returned error:`, response.data?.description); + return { success: false, message: `测试 Telegram 发送失败: ${response.data?.description || 'API 返回失败'}` }; + } + } catch (error: any) { + const errorMessage = error.response?.data?.description || error.response?.data || error.message || '未知错误'; + console.error(`[Notification Test] Error sending test Telegram message:`, errorMessage); + return { success: false, message: `测试 Telegram 发送失败: ${errorMessage}` }; + } + } + // --- Core Notification Sending Logic --- async sendNotification(event: NotificationEvent, details?: Record | string): Promise { console.log(`[Notification] Event triggered: ${event}`, details || ''); + + // 1. Get user's preferred language (or default) + let userLang = defaultLng; + try { + // Assuming settingsService is available or needs instantiation if not singleton + const langSetting = await settingsService.getSetting('language'); + if (langSetting && supportedLngs.includes(langSetting)) { + userLang = langSetting; + } + } catch (error) { + console.error(`[Notification] Error fetching language setting for event ${event}:`, error); + // Proceed with default language + } + console.log(`[Notification] Using language '${userLang}' for event ${event}`); + const payload: NotificationPayload = { event, timestamp: Date.now(), @@ -110,11 +226,11 @@ export class NotificationService { const sendPromises = applicableSettings.map(setting => { switch (setting.channel_type) { case 'webhook': - return this._sendWebhook(setting, payload); + return this._sendWebhook(setting, payload, userLang); // Pass userLang case 'email': - return this._sendEmail(setting, payload); + return this._sendEmail(setting, payload, userLang); // Pass userLang case 'telegram': - return this._sendTelegram(setting, payload); + return this._sendTelegram(setting, payload, userLang); // Pass userLang default: console.warn(`[Notification] Unknown channel type: ${setting.channel_type} for setting ID ${setting.id}`); return Promise.resolve(); // Don't fail all if one is unknown @@ -145,16 +261,22 @@ export class NotificationService { return rendered; } - - private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload): Promise { + // Updated to accept userLang + private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise { const config = setting.config as WebhookConfig; if (!config.url) { console.error(`[Notification] Webhook setting ID ${setting.id} is missing URL.`); return; } - const defaultBody = JSON.stringify(payload, null, 2); - const requestBody = this._renderTemplate(config.bodyTemplate, payload, defaultBody); + // Translate payload details if they match a known key structure + const translatedDetails = this._translatePayloadDetails(payload.details, userLang); + const translatedPayload = { ...payload, details: translatedDetails }; + + const defaultBody = JSON.stringify(translatedPayload, null, 2); + // Note: Webhook body templates might need adjustments if they expect specific structures + // or if they should also be translated. For now, we only translate the 'details'. + const requestBody = this._renderTemplate(config.bodyTemplate, translatedPayload, defaultBody); const requestConfig: AxiosRequestConfig = { method: config.method || 'POST', @@ -177,7 +299,8 @@ export class NotificationService { } } - private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload): Promise { + // Updated to accept userLang + private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise { const config = setting.config as EmailConfig; if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) { console.error(`[Notification] Email setting ID ${setting.id} is missing required SMTP configuration (to, smtpHost, smtpPort, from).`); @@ -198,14 +321,27 @@ export class NotificationService { const transporter = nodemailer.createTransport(transporterOptions); - const defaultSubject = `星枢终端通知: ${payload.event}`; - const subject = this._renderTemplate(config.subjectTemplate, payload, defaultSubject); + // Translate subject and body using i18next + // const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error + const i18nOptions: Record = { lng: userLang }; + if (payload.details && typeof payload.details === 'object') { + Object.assign(i18nOptions, payload.details); // Merge details if it's an object + } else if (payload.details !== undefined) { + i18nOptions.details = payload.details; // Pass non-object details directly if needed + } - // Basic default body (plain text) + // Try to translate the event itself for the subject, fallback to event name + const defaultSubjectKey = `event.${payload.event}`; + const defaultSubjectFallback = `星枢终端通知: ${payload.event}`; + const subjectText = i18next.t(defaultSubjectKey, { ...i18nOptions, defaultValue: defaultSubjectFallback }); + const subject = this._renderTemplate(config.subjectTemplate, payload, subjectText); // Allow template override + + // Translate the main body content based on event type if a key exists + const bodyKey = `eventBody.${payload.event}`; const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2); - const defaultBody = `事件: ${payload.event}\n时间: ${new Date(payload.timestamp).toISOString()}\n详情:\n${detailsString}`; - // Note: Email body templates are not implemented in this version. Using default text. - const body = defaultBody; + const defaultBodyText = `事件: ${payload.event}\n时间: ${new Date(payload.timestamp).toISOString()}\n详情:\n${detailsString}`; + const body = i18next.t(bodyKey, { ...i18nOptions, defaultValue: defaultBodyText }); + // Note: Email body templates are not implemented in this version. Using translated/default text. const mailOptions: Mail.Options = { from: config.from, @@ -224,18 +360,29 @@ export class NotificationService { } } - private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload): Promise { + // Updated to accept userLang + private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise { const config = setting.config as TelegramConfig; if (!config.botToken || !config.chatId) { console.error(`[Notification] Telegram setting ID ${setting.id} is missing botToken or chatId.`); return; } - // Default message format + // Translate message using i18next + // const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error + const i18nOptions: Record = { lng: userLang }; + if (payload.details && typeof payload.details === 'object') { + Object.assign(i18nOptions, payload.details); // Merge details if it's an object + } else if (payload.details !== undefined) { + i18nOptions.details = payload.details; // Pass non-object details directly if needed + } + const messageKey = `eventBody.${payload.event}`; // Use same key as email body for consistency const detailsStr = payload.details ? `\n详情: \`\`\`\n${typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details, null, 2)}\n\`\`\`` : ''; - const defaultMessage = `*星枢终端通知*\n\n事件: \`${payload.event}\`\n时间: ${new Date(payload.timestamp).toISOString()}${detailsStr}`; + const defaultMessageText = `*星枢终端通知*\n\n事件: \`${payload.event}\`\n时间: ${new Date(payload.timestamp).toISOString()}${detailsStr}`; + const translatedBody = i18next.t(messageKey, { ...i18nOptions, defaultValue: defaultMessageText }); - const messageText = this._renderTemplate(config.messageTemplate, payload, defaultMessage); + // Allow template override + const messageText = this._renderTemplate(config.messageTemplate, payload, translatedBody); const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`; try { @@ -251,6 +398,41 @@ export class NotificationService { console.error(`[Notification] Error sending Telegram message for setting ID ${setting.id}:`, errorMessage); } } + + // Helper to attempt translation of known payload structures + private _translatePayloadDetails(details: any, lng: string): any { + if (!details || typeof details !== 'object') { + return details; // Return as is if not an object or null/undefined + } + + // Example: Translate connection test results + if (details.testResult === 'success' && details.connectionName) { + return { + ...details, + message: i18next.t('connection.testSuccess', { lng, name: details.connectionName, defaultValue: `Connection test successful for '${details.connectionName}'!` }) + }; + } + if (details.testResult === 'failed' && details.connectionName && details.error) { + return { + ...details, + message: i18next.t('connection.testFailed', { lng, name: details.connectionName, error: details.error, defaultValue: `Connection test failed for '${details.connectionName}': ${details.error}` }) + }; + } + + // Example: Translate settings update messages (can be expanded) + if (details.updatedKeys && Array.isArray(details.updatedKeys)) { + if (details.updatedKeys.includes('ipWhitelist')) { + return { ...details, message: i18next.t('settings.ipWhitelistUpdated', { lng, defaultValue: 'IP Whitelist updated successfully.' }) }; + } + // Generic settings update + return { ...details, message: i18next.t('settings.updated', { lng, defaultValue: 'Settings updated successfully.' }) }; + } + + + // Add more translation logic for other event details structures here... + + return details; // Return original details if no specific translation logic matched + } } // Optional: Export a singleton instance if needed throughout the backend diff --git a/packages/backend/src/types/notification.types.ts b/packages/backend/src/types/notification.types.ts index 3bd931a..62a2adb 100644 --- a/packages/backend/src/types/notification.types.ts +++ b/packages/backend/src/types/notification.types.ts @@ -17,6 +17,7 @@ export type NotificationEvent = | 'API_KEY_DELETED' | 'PASSKEY_ADDED' | 'PASSKEY_DELETED' + | 'IP_BLACKLISTED' // New event for IP blacklisting | 'SERVER_ERROR'; // Generic error event export interface WebhookConfig { diff --git a/packages/data/nexus-terminal.db b/packages/data/nexus-terminal.db index 79b8b29..6073a75 100644 Binary files a/packages/data/nexus-terminal.db and b/packages/data/nexus-terminal.db differ diff --git a/packages/frontend/src/components/NotificationSettingForm.vue b/packages/frontend/src/components/NotificationSettingForm.vue index e2248af..aefdb4f 100644 --- a/packages/frontend/src/components/NotificationSettingForm.vue +++ b/packages/frontend/src/components/NotificationSettingForm.vue @@ -91,12 +91,7 @@ {{ $t('settings.notifications.form.smtpFromHelp') }} - - {{ testResult.message }} - {{ $t('settings.notifications.form.saveToTest') }} +
@@ -115,8 +110,33 @@ {{ $t('settings.notifications.form.templateHelp') }}
+ + +
+ + + + + {{ $t('settings.notifications.form.fillRequiredToTest') }} + + + + {{ testResult.message }} + +
+ +
@@ -192,11 +212,30 @@ const testResult = ref<{ success: boolean; message: string } | null>(null); const isEditing = computed(() => !!props.initialData?.id); +// Computed property to check if necessary fields for testing unsaved config are filled +const canTestUnsaved = computed(() => { + if (isEditing.value) return true; // Always allow testing saved settings + + switch (formData.channel_type) { + case 'webhook': + return !!webhookConfig.value.url && !headerError.value; + case 'email': + return !!emailConfig.value.to && !!emailConfig.value.smtpHost && !!emailConfig.value.smtpPort && !!emailConfig.value.from; + case 'telegram': + return !!telegramConfig.value.botToken && !!telegramConfig.value.chatId; + default: + return false; + } +}); + + // 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' + 'TAG_DELETED', 'API_KEY_ADDED', 'API_KEY_DELETED', 'PASSKEY_ADDED', 'PASSKEY_DELETED', + 'IP_BLACKLISTED', // Add the new event here + 'SERVER_ERROR' ]; // Reactive form data structure @@ -352,17 +391,52 @@ const handleCancel = () => { }; const handleTestNotification = async () => { - if (!props.initialData?.id || formData.channel_type !== 'email') return; + // Allow testing if editing OR if adding and required fields are filled + if (!isEditing.value && !canTestUnsaved.value) 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 }; + let testConfig: any = {}; + // Prepare the config based on the current channel type + switch (formData.channel_type) { + case 'webhook': + testConfig = { ...webhookConfig.value }; + // Ensure headers are parsed correctly before sending + try { + testConfig.headers = JSON.parse(webhookHeadersString.value || '{}'); + if (typeof testConfig.headers !== 'object' || testConfig.headers === null || Array.isArray(testConfig.headers)) { + throw new Error('Headers must be a JSON object.'); + } + } catch (e: any) { + testResult.value = { success: false, message: t('settings.notifications.form.invalidJson') + `: ${e.message}` }; + testingNotification.value = false; + return; + } + break; + case 'email': + testConfig = { ...emailConfig.value }; + break; + case 'telegram': + testConfig = { ...telegramConfig.value }; + break; + default: + console.error("Unknown channel type for testing:", formData.channel_type); + testResult.value = { success: false, message: "未知渠道类型无法测试" }; + testingNotification.value = false; + return; + } try { - const result = await store.testSetting(props.initialData.id, testConfig); + let result: { success: boolean; message: string }; + if (isEditing.value && props.initialData?.id) { + // Test existing setting + result = await store.testSetting(props.initialData.id, testConfig); + } else { + // Test unsaved setting + result = await store.testUnsavedSetting(formData.channel_type, testConfig); + } testResult.value = { success: true, message: result.message || t('settings.notifications.form.testSuccess') }; } catch (error: any) { console.error("Test notification error:", error); diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 4c27436..962ccc5 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -7,20 +7,26 @@ import zhMessages from './locales/zh.json'; // 类型推断 (可选,但推荐) type MessageSchema = typeof enMessages; // 假设 en.json 包含所有 key -// 获取浏览器语言或默认语言 -const getInitialLocale = (): string => { - const navigatorLang = navigator.language?.split('-')[0]; // 获取 'en', 'zh' 等 - if (navigatorLang === 'zh') { - return 'zh'; +// 定义默认语言 +export const defaultLng = 'en'; +const localStorageKey = 'user-locale'; + +// 尝试从 localStorage 获取语言,否则回退 +const getInitialLocaleFromStorage = (): 'en' | 'zh' => { + const storedLocale = localStorage.getItem(localStorageKey); + if (storedLocale === 'en' || storedLocale === 'zh') { + return storedLocale; } - // 可以添加更多语言支持 - return 'en'; // 默认英文 + // Fallback logic (e.g., browser language or default) + const navigatorLang = navigator.language?.split('-')[0]; + return navigatorLang === 'zh' ? 'zh' : defaultLng; }; + const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({ legacy: false, // 必须设置为 false 才能在 Composition API 中使用 useI18n - locale: getInitialLocale(), // 设置初始语言 - fallbackLocale: 'en', // 如果当前语言缺少某个 key,则回退到英文 + locale: getInitialLocaleFromStorage(), // 使用从 localStorage 或回退获取的初始语言 + fallbackLocale: defaultLng, // 如果当前语言缺少某个 key,则回退到默认语言 messages: { en: enMessages, zh: zhMessages, @@ -30,4 +36,23 @@ const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({ // silentFallbackWarn: true, }); +/** + * 设置 i18n 实例的区域设置 + * @param lang 要设置的语言代码 ('en', 'zh', etc.) + */ +export const setLocale = (lang: 'en' | 'zh') => { + if (i18n.global.availableLocales.includes(lang)) { + i18n.global.locale = lang; // 直接赋值 + try { + localStorage.setItem(localStorageKey, lang); // 持久化到 localStorage + console.log(`[i18n] Locale set to "${lang}" and saved to localStorage.`); // 添加日志 + } catch (e) { + console.error('[i18n] Failed to save locale to localStorage:', e); + } + } else { + console.warn(`[i18n] Locale "${lang}" is not available.`); + } +}; + + export default i18n; diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index d7ed3a3..0919b35 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -342,6 +342,18 @@ "saveFailed": "Failed to save IP whitelist." } }, + "language": { + "title": "Language Settings", + "selectLabel": "Interface Language:", + "saveButton": "Save Language", + "success": { + "saved": "Language settings saved successfully." + }, + "error": { + "fetchFailed": "Failed to fetch language settings.", + "saveFailed": "Failed to save language settings." + } + }, "passkey": { "title": "Passkey Settings", "description": "Use Passkeys (biometrics or security keys) for passwordless authentication to enhance security and convenience.", @@ -383,7 +395,7 @@ "webhookMethod": "HTTP Method:", "webhookHeaders": "Custom Headers", "webhookBodyTemplate": "Body Template (Optional)", - "webhookBodyPlaceholder": "Default: JSON payload. Use {{event}}, {{timestamp}}, {{details}}.", + "webhookBodyPlaceholder": "Default: JSON payload. Use {{{{event}}}}, {{{{timestamp}}}}, {{{{details}}}}.", "emailTo": "Recipient Email(s):", "emailToHelp": "Comma-separated list.", "emailSubjectTemplate": "Subject Template (Optional)", @@ -396,16 +408,17 @@ "smtpFrom": "Sender Email:", "smtpFromHelp": "Email address used in the 'From' field.", "testButton": "Test Notification", - "testSuccess": "Test email sent successfully!", - "testFailed": "Test email failed", + "testSuccess": "Test notification sent successfully!", + "testFailed": "Test notification failed", "saveToTest": "Save the settings before testing.", + "fillRequiredToTest": "Fill required fields to enable 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}}.", + "telegramMessagePlaceholder": "Default: Markdown format. Use {{{{event}}}}, {{{{timestamp}}}}, {{{{details}}}}.", "enabledEvents": "Enabled Events:", - "templateHelp": "Placeholders: {{event}}, {{timestamp}}, {{details}} (JSON string)", + "templateHelp": "Placeholders: {{{{event}}}}, {{{{timestamp}}}}, {{{{details}}}} (JSON string)", "invalidJson": "Invalid JSON" }, "events": { @@ -425,6 +438,7 @@ "API_KEY_DELETED": "API Key Deleted", "PASSKEY_ADDED": "Passkey Added", "PASSKEY_DELETED": "Passkey Deleted", + "IP_BLACKLISTED": "IP Blacklisted", "SERVER_ERROR": "Server Error" } } diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index 251e0f9..2dbf50a 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -346,6 +346,18 @@ "saveFailed": "保存 IP 白名单失败。" } }, + "language": { + "title": "语言设置", + "selectLabel": "界面语言:", + "saveButton": "保存语言", + "success": { + "saved": "语言设置已成功保存。" + }, + "error": { + "fetchFailed": "获取语言设置失败。", + "saveFailed": "保存语言设置失败。" + } + }, "passkey": { "title": "Passkey 设置", "description": "使用 Passkey(生物识别或安全密钥)进行无密码认证,提升账户安全性和登录便捷性。", @@ -400,9 +412,10 @@ "smtpFrom": "发件人邮箱:", "smtpFromHelp": "用于邮件 'From' 字段的地址。", "testButton": "测试通知", - "testSuccess": "测试邮件发送成功!", - "testFailed": "测试邮件发送失败", + "testSuccess": "测试通知发送成功!", + "testFailed": "测试通知发送失败", "saveToTest": "请先保存设置再进行测试。", + "fillRequiredToTest": "请填写必填字段以启用测试。", "telegramToken": "机器人 Token:", "telegramTokenHelp": "请安全存储。建议使用环境变量。", "telegramChatId": "聊天 ID:", @@ -429,6 +442,7 @@ "API_KEY_DELETED": "API 密钥已删除", "PASSKEY_ADDED": "Passkey 已添加", "PASSKEY_DELETED": "Passkey 已删除", + "IP_BLACKLISTED": "IP 已被拉黑", "SERVER_ERROR": "服务器错误" } } diff --git a/packages/frontend/src/main.ts b/packages/frontend/src/main.ts index 7fa2a7d..14b2f36 100644 --- a/packages/frontend/src/main.ts +++ b/packages/frontend/src/main.ts @@ -4,6 +4,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; // 引入 import App from './App.vue'; import router from './router'; // 引入我们创建的 router import i18n from './i18n'; // 引入 i18n 实例 +import { useSettingsStore } from './stores/settings.store'; // 引入 Settings Store import './style.css'; const pinia = createPinia(); // 创建 Pinia 实例 @@ -15,4 +16,12 @@ app.use(pinia); // 使用配置好的 Pinia 实例 app.use(router); // 使用 Router app.use(i18n); // 使用 i18n -app.mount('#app'); +// 在挂载应用前加载初始设置 +const settingsStore = useSettingsStore(pinia); // 需要传递 pinia 实例 +settingsStore.loadInitialSettings().then(() => { + app.mount('#app'); // 确保设置加载完成后再挂载 +}).catch(error => { + console.error("Failed to load initial settings before mounting app:", error); + // 即使加载失败,也尝试挂载应用,可能使用默认设置 + app.mount('#app'); +}); diff --git a/packages/frontend/src/stores/notifications.store.ts b/packages/frontend/src/stores/notifications.store.ts index 3c496a9..d38190f 100644 --- a/packages/frontend/src/stores/notifications.store.ts +++ b/packages/frontend/src/stores/notifications.store.ts @@ -1,7 +1,7 @@ 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'; +import { NotificationSetting, NotificationSettingData, NotificationChannelType } from '../types/server.types'; // Import NotificationChannelType export const useNotificationsStore = defineStore('notifications', () => { const settings = ref([]); @@ -94,6 +94,19 @@ export const useNotificationsStore = defineStore('notifications', () => { // No finally block needed here as loading state is managed in the component }; + // Test an unsaved setting configuration + const testUnsavedSetting = async (channelType: NotificationChannelType, config: any): Promise<{ success: boolean; message: string }> => { + error.value = null; + try { + // Send the channel type and config in the request body + const response = await axios.post<{ message: string }>(`/api/v1/notifications/test-unsaved`, { channel_type: channelType, config }); + return { success: true, message: response.data.message || '测试成功' }; + } catch (err: any) { + console.error(`Error testing unsaved notification setting:`, err); + throw err; // Re-throw the error to be caught in the component + } + }; + // Computed property to get settings by type (example) const webhookSettings = computed(() => settings.value.filter(s => s.channel_type === 'webhook')); @@ -108,7 +121,8 @@ export const useNotificationsStore = defineStore('notifications', () => { addSetting, updateSetting, deleteSetting, - testSetting, // Add the new function here + testSetting, + testUnsavedSetting, // Add the new function here webhookSettings, emailSettings, telegramSettings, diff --git a/packages/frontend/src/stores/settings.store.ts b/packages/frontend/src/stores/settings.store.ts new file mode 100644 index 0000000..9b30954 --- /dev/null +++ b/packages/frontend/src/stores/settings.store.ts @@ -0,0 +1,131 @@ +import { defineStore } from 'pinia'; +import axios from 'axios'; +import { ref, computed } from 'vue'; // Import computed +import i18n, { setLocale, defaultLng } from '../i18n'; // Import i18n instance and setLocale + +// Define the type for settings state explicitly +interface SettingsState { + language: 'en' | 'zh'; + ipWhitelist: string; + maxLoginAttempts: string; + loginBanDuration: string; + // Add other settings keys here as needed + [key: string]: string; // Allow other string settings +} + +export const useSettingsStore = defineStore('settings', () => { + // --- State --- + const settings = ref>({}); // Use Partial initially + const isLoading = ref(false); + const error = ref(null); + + // --- Actions --- + + /** + * Fetches all settings from the backend and updates the store state. + * Also sets the i18n locale based on the fetched language setting. + * Should be called early in the application lifecycle (e.g., main.ts). + */ + async function loadInitialSettings() { + isLoading.value = true; + error.value = null; + let fetchedLang: 'en' | 'zh' | undefined; + + try { + console.log('[SettingsStore] Starting loadInitialSettings...'); // 添加日志 + const response = await axios.get>('/api/v1/settings'); + settings.value = response.data; // Store all fetched settings + console.log('[SettingsStore] Fetched settings:', JSON.stringify(settings.value)); // 添加日志 + + // Determine and apply language + const langFromSettings = settings.value.language; + if (langFromSettings === 'en' || langFromSettings === 'zh') { + fetchedLang = langFromSettings; + } else { + // Fallback logic if setting is missing or invalid + const navigatorLang = navigator.language?.split('-')[0]; + fetchedLang = navigatorLang === 'zh' ? 'zh' : defaultLng; // Use browser lang or default + console.warn(`[SettingsStore] Language setting not found or invalid ('${langFromSettings}'). Falling back to '${fetchedLang}'.`); + // Optionally save the fallback language back to the backend if desired + // await updateSetting('language', fetchedLang); + } + + // Ensure fetchedLang is valid before calling setLocale + if (fetchedLang) { + console.log(`[SettingsStore] Determined language: ${fetchedLang}. Applying locale...`); // 添加日志 + setLocale(fetchedLang); // Apply the determined locale + } else { + // This case should ideally not happen due to fallback logic, but as a safeguard: + console.error('[SettingsStore] Could not determine a valid language to set.'); + setLocale(defaultLng); // Fallback to default if determination failed + } + + + } catch (err: any) { + console.error('Failed to load initial settings:', err); + error.value = err.response?.data?.message || err.message || 'Failed to load settings'; + // Apply default language on error + setLocale(defaultLng); + } finally { + isLoading.value = false; + } + } + + /** + * Updates a single setting value both locally and on the backend. + * @param key The setting key to update. + * @param value The new value for the setting. + */ + async function updateSetting(key: keyof SettingsState, value: string) { + const previousValue = settings.value[key]; + settings.value = { ...settings.value, [key]: value }; // Optimistic update + + try { + await axios.put('/api/v1/settings', { [key]: value }); + // If updating language, also update i18n + if (key === 'language' && (value === 'en' || value === 'zh')) { + setLocale(value); + } + } catch (err: any) { + console.error(`Failed to update setting '${key}':`, err); + settings.value = { ...settings.value, [key]: previousValue }; // Revert on error + throw new Error(err.response?.data?.message || err.message || `Failed to update setting '${key}'`); + } + } + + /** + * Updates multiple settings values both locally and on the backend. + * @param updates An object containing key-value pairs of settings to update. + */ + async function updateMultipleSettings(updates: Partial) { + const previousSettings = { ...settings.value }; + settings.value = { ...settings.value, ...updates }; // Optimistic update + + try { + await axios.put('/api/v1/settings', updates); + // If language is updated, apply it + if (updates.language && (updates.language === 'en' || updates.language === 'zh')) { + setLocale(updates.language); + } + } catch (err: any) { + console.error('Failed to update multiple settings:', err); + settings.value = previousSettings; // Revert on error + throw new Error(err.response?.data?.message || err.message || 'Failed to update settings'); + } + } + + + // --- Getters --- + // Example getter (can add more as needed) + const language = computed(() => settings.value.language || defaultLng); + + return { + settings, + isLoading, + error, + language, // Expose getter + loadInitialSettings, + updateSetting, + updateMultipleSettings, + }; +}); diff --git a/packages/frontend/src/types/server.types.ts b/packages/frontend/src/types/server.types.ts index 1d01fa8..76bbc30 100644 --- a/packages/frontend/src/types/server.types.ts +++ b/packages/frontend/src/types/server.types.ts @@ -35,6 +35,7 @@ export type NotificationEvent = | 'API_KEY_DELETED' | 'PASSKEY_ADDED' | 'PASSKEY_DELETED' + | 'IP_BLACKLISTED' // Add the new event type here as well | 'SERVER_ERROR'; export interface WebhookConfig { diff --git a/packages/frontend/src/views/SettingsView.vue b/packages/frontend/src/views/SettingsView.vue index 03769b5..5d6fede 100644 --- a/packages/frontend/src/views/SettingsView.vue +++ b/packages/frontend/src/views/SettingsView.vue @@ -2,6 +2,10 @@

{{ $t('settings.title') }}

+ +
{{ $t('common.loading') }}
+
{{ settingsError }}
+

{{ $t('settings.changePassword.title') }}

@@ -22,11 +26,25 @@
- + +
+

{{ $t('settings.language.title') }}

+
+
+ + +
+ +

{{ languageMessage }}

+
+

-
+

Passkey 设置

使用 Passkey(无密码认证)提升安全性和便捷性。您可以注册新的 Passkey 用于登录。

@@ -92,13 +110,11 @@ {{ $t('settings.ipWhitelist.hint') }}
- +

{{ ipWhitelistMessage }}

- -
@@ -110,14 +126,14 @@
- +
- +
- -

{{ blacklistSettings.message }}

+ +

{{ blacklistSettingsMessage }}


@@ -156,15 +172,6 @@

当前没有 IP 地址在黑名单中。

- - -

{{ blacklistDeleteError }}

@@ -173,168 +180,97 @@