diff --git a/package-lock.json b/package-lock.json index 2508c96..ca656b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2900,6 +2900,15 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -7534,6 +7543,8 @@ "@types/uuid": "^10.0.0", "axios": "^1.8.4", "bcrypt": "^5.1.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "dotenv": "^16.5.0", "express": "^5.1.0", "express-session": "^1.18.1", diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 55dae4b..d5ed9a9 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -29,8 +29,10 @@ COPY --from=builder /app/packages/backend/dist ./dist # --- 添加:复制 locales 目录 --- COPY --from=builder /app/packages/backend/src/locales ./dist/locales # --- 结束添加 --- -COPY --from=builder /app/packages/backend/package.json ./package.json -COPY --from=builder /app/package-lock.json ./package-lock.json +# --- 修改:从构建上下文复制 package 文件,以包含新依赖 --- +COPY packages/backend/package.json ./package.json +COPY package-lock.json ./package-lock.json +# --- 结束修改 --- RUN npm install --omit=dev --prefer-offline diff --git a/packages/backend/package.json b/packages/backend/package.json index 7bfda59..ede1508 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -15,6 +15,8 @@ "@types/uuid": "^10.0.0", "axios": "^1.8.4", "bcrypt": "^5.1.1", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "dotenv": "^16.5.0", "express": "^5.1.0", "express-session": "^1.18.1", diff --git a/packages/backend/src/i18n.ts b/packages/backend/src/i18n.ts index ab945dd..25d5cf4 100644 --- a/packages/backend/src/i18n.ts +++ b/packages/backend/src/i18n.ts @@ -5,9 +5,6 @@ import fs from 'fs'; // --- 动态确定支持的语言 --- const localesDir = path.join(__dirname, 'locales'); -// --- 添加调试日志 --- -console.log(`[i18next-debug] Calculated locales directory path: ${localesDir}`); -// --- 结束调试日志 --- let dynamicSupportedLngs: string[] = []; try { const entries = fs.readdirSync(localesDir, { withFileTypes: true }); diff --git a/packages/backend/src/repositories/settings.repository.ts b/packages/backend/src/repositories/settings.repository.ts index 5084ba1..ba2534f 100644 --- a/packages/backend/src/repositories/settings.repository.ts +++ b/packages/backend/src/repositories/settings.repository.ts @@ -267,6 +267,7 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise< statusMonitorIntervalSeconds: '3', [SIDEBAR_CONFIG_KEY]: JSON.stringify(defaultSidebarPanesStructure), [CAPTCHA_CONFIG_KEY]: JSON.stringify(defaultCaptchaSettings), + timezone: 'UTC', // NEW: 添加时区默认值 }; const nowSeconds = Math.floor(Date.now() / 1000); const sqlInsertOrIgnore = `INSERT OR IGNORE INTO settings (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)`; diff --git a/packages/backend/src/services/notification.service.ts b/packages/backend/src/services/notification.service.ts index b39b40e..8f2d651 100644 --- a/packages/backend/src/services/notification.service.ts +++ b/packages/backend/src/services/notification.service.ts @@ -14,6 +14,7 @@ import * as nodemailer from 'nodemailer'; import Mail from 'nodemailer/lib/mailer'; import i18next, { defaultLng, supportedLngs } from '../i18n'; // Import supportedLngs import { settingsService } from './settings.service'; +import { formatInTimeZone } from 'date-fns-tz'; // NEW: Import timezone formatting const testSubjectKey = 'testNotification.subject'; @@ -269,24 +270,27 @@ export class NotificationService { console.log(`[通知] 事件触发: ${event}`, details || ''); let userLang = defaultLng; + let userTimezone = 'UTC'; // NEW: Default timezone try { - const langSetting = await settingsService.getSetting('language'); - // --- 添加调试日志 --- - console.log(`[通知调试] 刚从数据库获取的 langSetting: ${langSetting}`); - // --- 结束调试日志 --- - // --- 添加调试日志 --- - console.log(`[通知调试] Checking langSetting against supportedLngs:`, supportedLngs); - // --- 结束调试日志 --- + // Fetch language and timezone settings concurrently + const [langSetting, timezoneSetting] = await Promise.all([ + settingsService.getSetting('language'), + settingsService.getSetting('timezone') // NEW: Fetch timezone + ]); if (langSetting && supportedLngs.includes(langSetting)) { userLang = langSetting; } + // NEW: Validate and set timezone + if (timezoneSetting) { + // Basic validation: Check if it's a non-empty string. + // More robust validation could involve checking against Intl.supportedValuesOf('timeZone') + // but that might be overkill depending on how timezones are set/validated elsewhere. + userTimezone = timezoneSetting; + } } catch (error) { - console.error(`[通知] 获取事件 ${event} 的语言设置时出错:`, error); + console.error(`[通知] 获取事件 ${event} 的语言或时区设置时出错:`, error); // Modified log } - // --- 添加调试日志 --- - console.log(`[通知调试] 最终决定使用的 userLang: ${userLang}`); - // --- 结束调试日志 --- - console.log(`[通知] 事件 ${event} 使用语言 '${userLang}'`); + console.log(`[通知] 事件 ${event} 使用语言 '${userLang}', 时区 '${userTimezone}'`); // Modified log const payload: NotificationPayload = { event, @@ -305,11 +309,11 @@ export class NotificationService { const sendPromises = applicableSettings.map(setting => { switch (setting.channel_type) { case 'webhook': - return this._sendWebhook(setting, payload, userLang); + return this._sendWebhook(setting, payload, userLang, userTimezone); // Pass timezone case 'email': - return this._sendEmail(setting, payload, userLang); + return this._sendEmail(setting, payload, userLang, userTimezone); // Pass timezone case 'telegram': - return this._sendTelegram(setting, payload, userLang); + return this._sendTelegram(setting, payload, userLang, userTimezone); // Pass timezone default: console.warn(`[通知] 未知渠道类型: ${setting.channel_type} (设置 ID: ${setting.id})`); return Promise.resolve(); // 如果有一个未知,不要让所有都失败 @@ -345,7 +349,7 @@ export class NotificationService { return rendered; } - private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise { + private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload, userLang: string, userTimezone: string): Promise { // Add userTimezone const config = setting.config as WebhookConfig; if (!config.url) { console.error(`[通知] Webhook 设置 ID ${setting.id} 缺少 URL。`); @@ -363,7 +367,8 @@ export class NotificationService { const templateDataWebhook: Record = { event: translatedPayload.event, eventDisplay: eventDisplayName, // Assuming no markdown needed for webhook - timestamp: new Date(translatedPayload.timestamp).toISOString(), + // NEW: Format timestamp using user's timezone + timestamp: formatInTimeZone(new Date(translatedPayload.timestamp), userTimezone, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"), // Example format, adjust as needed // Use the translated message if available, otherwise stringify details: (typeof translatedPayload.details === 'object' && translatedPayload.details?.message) ? translatedPayload.details.message @@ -394,7 +399,7 @@ export class NotificationService { } } - private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise { + private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload, userLang: string, userTimezone: string): Promise { // Add userTimezone const config = setting.config as EmailConfig; if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) { console.error(`[通知] 邮件设置 ID ${setting.id} 缺少必要的 SMTP 配置 (to, smtpHost, smtpPort, from)。`); @@ -432,7 +437,8 @@ export class NotificationService { const templateDataEmailSubject: Record = { event: payload.event, eventDisplay: eventDisplayName, // Assuming subject doesn't need markdown - timestamp: new Date(payload.timestamp).toISOString(), + // NEW: Format timestamp using user's timezone + timestamp: formatInTimeZone(new Date(payload.timestamp), userTimezone, "yyyy-MM-dd HH:mm:ss zzz"), // Example format for email details: typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2), // Add other relevant fields from i18nOptions if needed by subject template ...Object.entries(i18nOptions).reduce((acc, [key, value]) => { @@ -447,9 +453,11 @@ export class NotificationService { const bodyKey = `eventBody.${payload.event}`; const detailsString = typeof payload.details === 'string' ? payload.details : JSON.stringify(payload.details || {}, null, 2); - const defaultBodyText = `Event: ${eventDisplayName}\nTimestamp: ${new Date(payload.timestamp).toISOString()}\nDetails:\n${detailsString}`; - const body = i18next.t(bodyKey, { ...i18nOptions, defaultValue: defaultBodyText, eventDisplay: eventDisplayName }); - + // NEW: Use formatted timestamp in default body text + const formattedTimestampForEmail = formatInTimeZone(new Date(payload.timestamp), userTimezone, "yyyy-MM-dd HH:mm:ss zzz"); + const defaultBodyText = `Event: ${eventDisplayName}\nTimestamp: ${formattedTimestampForEmail}\nDetails:\n${detailsString}`; + // Pass formatted timestamp to i18n interpolation as well + const body = i18next.t(bodyKey, { ...i18nOptions, timestamp: formattedTimestampForEmail, defaultValue: defaultBodyText, eventDisplay: eventDisplayName }); const mailOptions: Mail.Options = { from: config.from, to: config.to, @@ -466,8 +474,8 @@ export class NotificationService { } } - private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise { - console.log(`[_sendTelegram] Initiating for event: ${payload.event}, Setting ID: ${setting.id}, Lang: ${userLang}`); + private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload, userLang: string, userTimezone: string): Promise { // Add userTimezone + console.log(`[_sendTelegram] Initiating for event: ${payload.event}, Setting ID: ${setting.id}, Lang: ${userLang}, Timezone: ${userTimezone}`); // Modified log console.log(`[_sendTelegram] Received payload:`, JSON.stringify(payload, null, 2)); const config = setting.config as TelegramConfig; if (!config.botToken || !config.chatId) { @@ -496,8 +504,8 @@ export class NotificationService { const templateData: Record = { // Assign the *translated* event name to the 'event' key (NO escaping) event: translatedEventName, - // ISO timestamp (usually safe) - timestamp: new Date(payload.timestamp).toISOString(), + // NEW: Format timestamp using user's timezone for Telegram (adjust format as needed) + timestamp: formatInTimeZone(new Date(payload.timestamp), userTimezone, "yyyy-MM-dd HH:mm:ss zzz"), // Formatted details string (NO escaping) details: detailsText // Note: We no longer create eventDisplay key diff --git a/packages/backend/src/settings/settings.controller.ts b/packages/backend/src/settings/settings.controller.ts index 60a72a4..b3c0b9b 100644 --- a/packages/backend/src/settings/settings.controller.ts +++ b/packages/backend/src/settings/settings.controller.ts @@ -42,7 +42,8 @@ export const settingsController = { 'sidebarPaneWidths', // +++ 添加侧边栏宽度对象键 +++ 'fileManagerRowSizeMultiplier', // +++ 添加文件管理器行大小键 +++ 'fileManagerColWidths', // +++ 添加文件管理器列宽键 +++ - 'commandInputSyncTarget' // +++ 添加命令输入同步目标键 +++ + 'commandInputSyncTarget', // +++ 添加命令输入同步目标键 +++ + 'timezone' // NEW: 添加时区键 ]; const filteredSettings: Record = {}; for (const key in settingsToUpdate) { diff --git a/packages/frontend/src/stores/settings.store.ts b/packages/frontend/src/stores/settings.store.ts index 183d08f..26df9e6 100644 --- a/packages/frontend/src/stores/settings.store.ts +++ b/packages/frontend/src/stores/settings.store.ts @@ -44,6 +44,7 @@ interface SettingsState { fileManagerRowSizeMultiplier?: string; // NEW: 文件管理器行大小乘数 (e.g., '1.0') fileManagerColWidths?: string; // NEW: 文件管理器列宽 JSON 字符串 (e.g., '{"name": 300, "size": 100}') commandInputSyncTarget?: 'quickCommands' | 'commandHistory' | 'none'; // NEW: 命令输入同步目标 + timezone?: string; // NEW: 时区设置 (e.g., 'Asia/Shanghai', 'UTC') // Add other general settings keys here as needed [key: string]: string | undefined; // Allow other string settings } @@ -206,6 +207,10 @@ export const useSettingsStore = defineStore('settings', () => { if (settings.value.commandInputSyncTarget === undefined) { settings.value.commandInputSyncTarget = 'none'; // 默认不同步 } + // NEW: Timezone default + if (settings.value.timezone === undefined) { + settings.value.timezone = 'UTC'; // 默认 UTC + } // --- 语言设置 --- const langFromSettings = settings.value.language; @@ -282,7 +287,8 @@ export const useSettingsStore = defineStore('settings', () => { 'sidebarPaneWidths', // +++ 添加侧边栏宽度对象键 +++ 'fileManagerRowSizeMultiplier', // +++ 添加文件管理器行大小键 +++ 'fileManagerColWidths', // +++ 添加文件管理器列宽键 +++ - 'commandInputSyncTarget' // +++ 添加命令输入同步目标键 +++ + 'commandInputSyncTarget', // +++ 添加命令输入同步目标键 +++ + 'timezone' // NEW: 添加时区键 ]; if (!allowedKeys.includes(key)) { console.error(`[SettingsStore] 尝试更新不允许的设置键: ${key}`); @@ -323,7 +329,8 @@ export const useSettingsStore = defineStore('settings', () => { 'sidebarPaneWidths', // +++ 添加侧边栏宽度对象键 +++ 'fileManagerRowSizeMultiplier', // +++ 添加文件管理器行大小键 +++ 'fileManagerColWidths', // +++ 添加文件管理器列宽键 +++ - 'commandInputSyncTarget' // +++ 添加命令输入同步目标键 +++ + 'commandInputSyncTarget', // +++ 添加命令输入同步目标键 +++ + 'timezone' // NEW: 添加时区键 ]; const filteredUpdates: Partial = {}; let languageUpdate: string | undefined = undefined; // Use string type @@ -547,6 +554,9 @@ export const useSettingsStore = defineStore('settings', () => { return 'none'; // Default to 'none' if invalid or not set }); + // NEW: Getter for timezone setting + const timezone = computed(() => settings.value.timezone || 'UTC'); // Fallback to UTC + // --- CAPTCHA Getters (Public Only) --- const isCaptchaEnabled = computed(() => captchaSettings.value?.enabled ?? false); const captchaProvider = computed(() => captchaSettings.value?.provider ?? 'none'); @@ -584,5 +594,6 @@ export const useSettingsStore = defineStore('settings', () => { updateSidebarPaneWidth, // +++ 暴露更新特定面板宽度的 action +++ updateFileManagerLayoutSettings, // +++ 暴露更新文件管理器布局的 action +++ commandInputSyncTarget, // +++ 暴露命令输入同步目标 getter +++ + timezone, // NEW: 暴露时区 getter }; }); diff --git a/packages/frontend/src/views/SettingsView.vue b/packages/frontend/src/views/SettingsView.vue index b4c7bff..656a473 100644 --- a/packages/frontend/src/views/SettingsView.vue +++ b/packages/frontend/src/views/SettingsView.vue @@ -483,6 +483,31 @@ +
+ +
+

{{ t('settings.timezone.title', '时区设置') }}

+
+
+ + + {{ t('settings.timezone.description', '通知中的时间戳将根据此时区进行格式化。') }} +
+
+ +

{{ timezoneMessage }}

+
+
+
@@ -591,7 +616,10 @@ const workspaceSidebarPersistentSuccess = ref(false); // 新增 const commandInputSyncLoading = ref(false); // NEW const commandInputSyncMessage = ref(''); // NEW const commandInputSyncSuccess = ref(false); // NEW - +const selectedTimezone = ref('UTC'); // 本地状态,用于时区 v-model +const timezoneLoading = ref(false); +const timezoneMessage = ref(''); +const timezoneSuccess = ref(false); // CAPTCHA Form State const captchaForm = reactive({ // Use reactive for the form object enabled: false, @@ -605,6 +633,17 @@ const captchaLoading = ref(false); const captchaMessage = ref(''); const captchaSuccess = ref(false); +// 提供一些常用的时区供选择 +const commonTimezones = ref([ + 'UTC', + 'Etc/GMT+12', 'Pacific/Midway', 'Pacific/Honolulu', 'America/Anchorage', + 'America/Los_Angeles', 'America/Denver', 'America/Chicago', 'America/New_York', + 'America/Caracas', 'America/Halifax', 'America/Sao_Paulo', 'Atlantic/Azores', + 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow', + 'Asia/Dubai', 'Asia/Karachi', 'Asia/Dhaka', 'Asia/Bangkok', + 'Asia/Shanghai', 'Asia/Tokyo', 'Australia/Sydney', 'Pacific/Auckland', + 'Etc/GMT-14' +]); // --- Watcher to sync local form state with store state --- watch(settings, (newSettings, oldSettings) => { @@ -625,6 +664,7 @@ watch(settings, (newSettings, oldSettings) => { statusMonitorIntervalLocal.value = statusMonitorIntervalSecondsNumber.value; // 同步状态监控间隔 workspaceSidebarPersistentEnabled.value = workspaceSidebarPersistentBoolean.value; // 新增:同步侧边栏固定设置 commandInputSyncTargetLocal.value = commandInputSyncTarget.value; // NEW: Sync command input sync target + selectedTimezone.value = newSettings.timezone || 'UTC'; // 同步时区设置 }, { deep: true, immediate: true }); // immediate: true to run on initial load @@ -795,6 +835,24 @@ const handleUpdateCommandInputSyncTarget = async () => { } }; +// --- Timezone setting method --- +const handleUpdateTimezone = async () => { + timezoneLoading.value = true; + timezoneMessage.value = ''; + timezoneSuccess.value = false; + try { + await settingsStore.updateSetting('timezone', selectedTimezone.value); + timezoneMessage.value = t('settings.timezone.success.saved', '时区设置已保存'); // 需要添加翻译 + timezoneSuccess.value = true; + } catch (error: any) { + console.error('更新时区设置失败:', error); + timezoneMessage.value = error.message || t('settings.timezone.error.saveFailed', '保存时区设置失败'); // 需要添加翻译 + timezoneSuccess.value = false; + } finally { + timezoneLoading.value = false; + } +}; + // --- 外观设置 --- const openStyleCustomizer = () => { appearanceStore.toggleStyleCustomizer(true);