update
This commit is contained in:
Generated
+78
@@ -54,6 +54,18 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.27.0",
|
"version": "7.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
|
||||||
@@ -939,6 +951,16 @@
|
|||||||
"undici-types": "~6.19.2"
|
"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": {
|
"node_modules/@types/qrcode": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||||
@@ -3073,6 +3095,43 @@
|
|||||||
"ms": "^2.0.0"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -3974,6 +4033,15 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/nopt": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||||
@@ -4594,6 +4662,12 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -6149,7 +6223,10 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"i18next": "^25.0.0",
|
||||||
|
"i18next-fs-backend": "^2.6.0",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
"socks": "^2.8.4",
|
"socks": "^2.8.4",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
@@ -6162,6 +6239,7 @@
|
|||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/express-session": "^1.18.1",
|
"@types/express-session": "^1.18.1",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/sqlite3": "^3.1.11",
|
"@types/sqlite3": "^3.1.11",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
|||||||
@@ -18,7 +18,10 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"i18next": "^25.0.0",
|
||||||
|
"i18next-fs-backend": "^2.6.0",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
|
"nodemailer": "^6.10.1",
|
||||||
"socks": "^2.8.4",
|
"socks": "^2.8.4",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/express-session": "^1.18.1",
|
"@types/express-session": "^1.18.1",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/sqlite3": "^3.1.11",
|
"@types/sqlite3": "^3.1.11",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"connection": {
|
||||||
|
"testSuccess": "连接 '{name}' 测试成功!",
|
||||||
|
"testFailed": "连接 '{name}' 测试失败: {error}"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"updated": "设置已成功更新。",
|
||||||
|
"ipWhitelistUpdated": "IP 白名单已成功更新。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,17 +120,11 @@ export class NotificationController {
|
|||||||
const originalSetting = await this.notificationService.getSettingById(id);
|
const originalSetting = await this.notificationService.getSettingById(id);
|
||||||
if (!originalSetting) {
|
if (!originalSetting) {
|
||||||
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
|
res.status(404).json({ message: `未找到 ID 为 ${id} 的通知设置` });
|
||||||
return;
|
return; // Return early if setting not found
|
||||||
}
|
}
|
||||||
|
|
||||||
// Currently, only email testing is implemented
|
// Call the generic testSetting method from the service, passing the channel type
|
||||||
if (originalSetting.channel_type !== 'email') {
|
const result = await this.notificationService.testSetting(originalSetting.channel_type, config);
|
||||||
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);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 记录审计日志 (可选,根据需要决定是否记录测试操作)
|
// 记录审计日志 (可选,根据需要决定是否记录测试操作)
|
||||||
@@ -147,4 +141,35 @@ export class NotificationController {
|
|||||||
res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message });
|
res.status(500).json({ message: '测试通知设置时发生内部错误', error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// POST /api/v1/notifications/test-unsaved
|
||||||
|
testUnsavedSetting = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ router.post('/', notificationController.create);
|
|||||||
router.put('/:id', notificationController.update);
|
router.put('/:id', notificationController.update);
|
||||||
router.delete('/:id', notificationController.delete);
|
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);
|
router.post('/:id/test', notificationController.testSetting);
|
||||||
|
|
||||||
|
// Route for testing an unsaved notification setting configuration
|
||||||
|
router.post('/test-unsaved', notificationController.testUnsavedSetting);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { getDb } from '../database';
|
import { getDb } from '../database';
|
||||||
import { settingsService } from './settings.service'; // 用于获取配置
|
import { settingsService } from './settings.service';
|
||||||
import * as sqlite3 from 'sqlite3'; // 导入 sqlite3 类型
|
import { NotificationService } from './notification.service'; // 导入 NotificationService
|
||||||
|
import * as sqlite3 from 'sqlite3';
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
const notificationService = new NotificationService(); // 实例化 NotificationService
|
||||||
|
|
||||||
// 黑名单相关设置的 Key
|
// 黑名单相关设置的 Key
|
||||||
const MAX_LOGIN_ATTEMPTS_KEY = 'maxLoginAttempts';
|
const MAX_LOGIN_ATTEMPTS_KEY = 'maxLoginAttempts';
|
||||||
@@ -96,11 +98,23 @@ export class IpBlacklistService {
|
|||||||
let blockedUntil = entry.blocked_until;
|
let blockedUntil = entry.blocked_until;
|
||||||
|
|
||||||
// 检查是否达到封禁阈值
|
// 检查是否达到封禁阈值
|
||||||
if (newAttempts >= maxAttempts) {
|
if (newAttempts >= maxAttempts && !entry.blocked_until) { // 只有在之前未被封禁时才触发通知
|
||||||
blockedUntil = now + banDuration;
|
blockedUntil = now + banDuration;
|
||||||
console.warn(`[IP Blacklist] IP ${ip} 登录失败次数达到 ${newAttempts} 次 (阈值 ${maxAttempts}),将被封禁 ${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<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
'UPDATE ip_blacklist SET attempts = ?, last_attempt_at = ?, blocked_until = ? WHERE ip = ?',
|
'UPDATE ip_blacklist SET attempts = ?, last_attempt_at = ?, blocked_until = ? WHERE ip = ?',
|
||||||
@@ -117,9 +131,17 @@ export class IpBlacklistService {
|
|||||||
} else {
|
} else {
|
||||||
// 插入新记录
|
// 插入新记录
|
||||||
let blockedUntil: number | null = null;
|
let blockedUntil: number | null = null;
|
||||||
if (1 >= maxAttempts) { // 首次尝试就达到阈值 (虽然不常见)
|
const attempts = 1; // 首次尝试
|
||||||
|
if (attempts >= maxAttempts) { // 首次尝试就达到阈值
|
||||||
blockedUntil = now + banDuration;
|
blockedUntil = now + banDuration;
|
||||||
console.warn(`[IP Blacklist] IP ${ip} 首次登录失败即达到阈值 ${maxAttempts},将被封禁 ${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<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import {
|
|||||||
NotificationEvent,
|
NotificationEvent,
|
||||||
NotificationPayload,
|
NotificationPayload,
|
||||||
WebhookConfig,
|
WebhookConfig,
|
||||||
EmailConfig, // Ensure EmailConfig is imported
|
EmailConfig,
|
||||||
TelegramConfig,
|
TelegramConfig,
|
||||||
NotificationChannelConfig
|
NotificationChannelConfig,
|
||||||
|
NotificationChannelType // Import the missing type
|
||||||
} from '../types/notification.types';
|
} from '../types/notification.types';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
import Mail from 'nodemailer/lib/mailer'; // Import Mail type for transporter
|
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 {
|
export class NotificationService {
|
||||||
private repository: NotificationSettingsRepository;
|
private repository: NotificationSettingsRepository;
|
||||||
@@ -43,10 +46,27 @@ export class NotificationService {
|
|||||||
return this.repository.delete(id);
|
return this.repository.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Test Notification Method ---
|
// --- Test Notification Methods ---
|
||||||
async testEmailSetting(config: EmailConfig): Promise<{ success: boolean; message: string }> {
|
|
||||||
|
// 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) {
|
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
|
// Let TypeScript infer the options type for SMTP
|
||||||
@@ -69,9 +89,9 @@ export class NotificationService {
|
|||||||
const mailOptions: Mail.Options = {
|
const mailOptions: Mail.Options = {
|
||||||
from: config.from,
|
from: config.from,
|
||||||
to: config.to, // Use the 'to' from config for testing
|
to: config.to, // Use the 'to' from config for testing
|
||||||
subject: '星枢终端 (Nexus Terminal) 测试邮件',
|
subject: 'Nexus Terminal Test Notification',
|
||||||
text: `这是一封来自星枢终端 (Nexus Terminal) 的测试邮件。\n\n如果收到此邮件,表示您的 SMTP 配置工作正常。\n\n时间: ${new Date().toISOString()}`,
|
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: `<p>这是一封来自 <b>星枢终端 (Nexus Terminal)</b> 的测试邮件。</p><p>如果收到此邮件,表示您的 SMTP 配置工作正常。</p><p>时间: ${new Date().toISOString()}</p>`,
|
html: `<p>This is a test email from <b>Nexus Terminal</b>.</p><p>If you received this, your SMTP configuration is working.</p><p>Timestamp: ${new Date().toISOString()}</p>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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 ---
|
// --- Core Notification Sending Logic ---
|
||||||
|
|
||||||
async sendNotification(event: NotificationEvent, details?: Record<string, any> | string): Promise<void> {
|
async sendNotification(event: NotificationEvent, details?: Record<string, any> | string): Promise<void> {
|
||||||
console.log(`[Notification] Event triggered: ${event}`, details || '');
|
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 = {
|
const payload: NotificationPayload = {
|
||||||
event,
|
event,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -110,11 +226,11 @@ export class NotificationService {
|
|||||||
const sendPromises = applicableSettings.map(setting => {
|
const sendPromises = applicableSettings.map(setting => {
|
||||||
switch (setting.channel_type) {
|
switch (setting.channel_type) {
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return this._sendWebhook(setting, payload);
|
return this._sendWebhook(setting, payload, userLang); // Pass userLang
|
||||||
case 'email':
|
case 'email':
|
||||||
return this._sendEmail(setting, payload);
|
return this._sendEmail(setting, payload, userLang); // Pass userLang
|
||||||
case 'telegram':
|
case 'telegram':
|
||||||
return this._sendTelegram(setting, payload);
|
return this._sendTelegram(setting, payload, userLang); // Pass userLang
|
||||||
default:
|
default:
|
||||||
console.warn(`[Notification] Unknown channel type: ${setting.channel_type} for setting ID ${setting.id}`);
|
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
|
return Promise.resolve(); // Don't fail all if one is unknown
|
||||||
@@ -145,16 +261,22 @@ export class NotificationService {
|
|||||||
return rendered;
|
return rendered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Updated to accept userLang
|
||||||
private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload): Promise<void> {
|
private async _sendWebhook(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
|
||||||
const config = setting.config as WebhookConfig;
|
const config = setting.config as WebhookConfig;
|
||||||
if (!config.url) {
|
if (!config.url) {
|
||||||
console.error(`[Notification] Webhook setting ID ${setting.id} is missing URL.`);
|
console.error(`[Notification] Webhook setting ID ${setting.id} is missing URL.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultBody = JSON.stringify(payload, null, 2);
|
// Translate payload details if they match a known key structure
|
||||||
const requestBody = this._renderTemplate(config.bodyTemplate, payload, defaultBody);
|
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 = {
|
const requestConfig: AxiosRequestConfig = {
|
||||||
method: config.method || 'POST',
|
method: config.method || 'POST',
|
||||||
@@ -177,7 +299,8 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload): Promise<void> {
|
// Updated to accept userLang
|
||||||
|
private async _sendEmail(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
|
||||||
const config = setting.config as EmailConfig;
|
const config = setting.config as EmailConfig;
|
||||||
if (!config.to || !config.smtpHost || !config.smtpPort || !config.from) {
|
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).`);
|
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 transporter = nodemailer.createTransport(transporterOptions);
|
||||||
|
|
||||||
const defaultSubject = `星枢终端通知: ${payload.event}`;
|
// Translate subject and body using i18next
|
||||||
const subject = this._renderTemplate(config.subjectTemplate, payload, defaultSubject);
|
// const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error
|
||||||
|
const i18nOptions: Record<string, any> = { 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 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}`;
|
const defaultBodyText = `事件: ${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 = i18next.t(bodyKey, { ...i18nOptions, defaultValue: defaultBodyText });
|
||||||
const body = defaultBody;
|
// Note: Email body templates are not implemented in this version. Using translated/default text.
|
||||||
|
|
||||||
const mailOptions: Mail.Options = {
|
const mailOptions: Mail.Options = {
|
||||||
from: config.from,
|
from: config.from,
|
||||||
@@ -224,18 +360,29 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload): Promise<void> {
|
// Updated to accept userLang
|
||||||
|
private async _sendTelegram(setting: NotificationSetting, payload: NotificationPayload, userLang: string): Promise<void> {
|
||||||
const config = setting.config as TelegramConfig;
|
const config = setting.config as TelegramConfig;
|
||||||
if (!config.botToken || !config.chatId) {
|
if (!config.botToken || !config.chatId) {
|
||||||
console.error(`[Notification] Telegram setting ID ${setting.id} is missing botToken or chatId.`);
|
console.error(`[Notification] Telegram setting ID ${setting.id} is missing botToken or chatId.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default message format
|
// Translate message using i18next
|
||||||
|
// const i18nOptions = { lng: userLang, ...payload.details }; // Original line causing error
|
||||||
|
const i18nOptions: Record<string, any> = { 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 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`;
|
const telegramApiUrl = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -251,6 +398,41 @@ export class NotificationService {
|
|||||||
console.error(`[Notification] Error sending Telegram message for setting ID ${setting.id}:`, errorMessage);
|
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
|
// Optional: Export a singleton instance if needed throughout the backend
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type NotificationEvent =
|
|||||||
| 'API_KEY_DELETED'
|
| 'API_KEY_DELETED'
|
||||||
| 'PASSKEY_ADDED'
|
| 'PASSKEY_ADDED'
|
||||||
| 'PASSKEY_DELETED'
|
| 'PASSKEY_DELETED'
|
||||||
|
| 'IP_BLACKLISTED' // New event for IP blacklisting
|
||||||
| 'SERVER_ERROR'; // Generic error event
|
| 'SERVER_ERROR'; // Generic error event
|
||||||
|
|
||||||
export interface WebhookConfig {
|
export interface WebhookConfig {
|
||||||
|
|||||||
Binary file not shown.
@@ -91,12 +91,7 @@
|
|||||||
<input type="email" id="smtp-from" v-model="emailConfig.from" class="form-control" required placeholder="sender@example.com">
|
<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>
|
<small class="text-muted">{{ $t('settings.notifications.form.smtpFromHelp') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" @click="handleTestNotification" class="btn btn-outline-secondary btn-sm" :disabled="!isEditing || testingNotification">
|
<!-- Removed duplicate test button from here -->
|
||||||
<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>
|
||||||
|
|
||||||
<div v-if="formData.channel_type === 'telegram'" class="channel-config mb-3 p-3 border rounded">
|
<div v-if="formData.channel_type === 'telegram'" class="channel-config mb-3 p-3 border rounded">
|
||||||
@@ -115,8 +110,33 @@
|
|||||||
<textarea id="telegram-message" v-model="telegramConfig.messageTemplate" class="form-control" rows="3" :placeholder="$t('settings.notifications.form.telegramMessagePlaceholder')"></textarea>
|
<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>
|
<small class="text-muted">{{ $t('settings.notifications.form.templateHelp') }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Test button moved below -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Unified Test Button Area -->
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<!-- Show button if editing OR if adding and required fields are filled -->
|
||||||
|
<button
|
||||||
|
v-if="isEditing || canTestUnsaved"
|
||||||
|
type="button"
|
||||||
|
@click="handleTestNotification"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
:disabled="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>
|
||||||
|
<!-- Show hint if adding and required fields are NOT filled -->
|
||||||
|
<small v-else class="d-block mt-2 text-muted">
|
||||||
|
{{ $t('settings.notifications.form.fillRequiredToTest') }}
|
||||||
|
</small>
|
||||||
|
<!-- Show test result message if available -->
|
||||||
|
<small v-if="testResult" :class="['d-block mt-2', testResult.success ? 'text-success' : 'text-danger']">
|
||||||
|
{{ testResult.message }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Enabled Events -->
|
<!-- Enabled Events -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">{{ $t('settings.notifications.form.enabledEvents') }}</label>
|
<label class="form-label">{{ $t('settings.notifications.form.enabledEvents') }}</label>
|
||||||
@@ -192,11 +212,30 @@ const testResult = ref<{ success: boolean; message: string } | null>(null);
|
|||||||
|
|
||||||
const isEditing = computed(() => !!props.initialData?.id);
|
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
|
// Define all possible events
|
||||||
const allNotificationEvents: NotificationEvent[] = [
|
const allNotificationEvents: NotificationEvent[] = [
|
||||||
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'CONNECTION_ADDED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED',
|
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'CONNECTION_ADDED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED',
|
||||||
'SETTINGS_UPDATED', 'PROXY_ADDED', 'PROXY_UPDATED', 'PROXY_DELETED', 'TAG_ADDED', 'TAG_UPDATED',
|
'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
|
// Reactive form data structure
|
||||||
@@ -352,17 +391,52 @@ const handleCancel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTestNotification = async () => {
|
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;
|
testingNotification.value = true;
|
||||||
testError.value = null;
|
testError.value = null;
|
||||||
testResult.value = null;
|
testResult.value = null;
|
||||||
|
|
||||||
// Use the current form values for testing, even if not saved yet
|
let testConfig: any = {};
|
||||||
const testConfig: SmtpEmailConfig = { ...emailConfig.value };
|
// 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 {
|
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') };
|
testResult.value = { success: true, message: result.message || t('settings.notifications.form.testSuccess') };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Test notification error:", error);
|
console.error("Test notification error:", error);
|
||||||
|
|||||||
@@ -7,20 +7,26 @@ import zhMessages from './locales/zh.json';
|
|||||||
// 类型推断 (可选,但推荐)
|
// 类型推断 (可选,但推荐)
|
||||||
type MessageSchema = typeof enMessages; // 假设 en.json 包含所有 key
|
type MessageSchema = typeof enMessages; // 假设 en.json 包含所有 key
|
||||||
|
|
||||||
// 获取浏览器语言或默认语言
|
// 定义默认语言
|
||||||
const getInitialLocale = (): string => {
|
export const defaultLng = 'en';
|
||||||
const navigatorLang = navigator.language?.split('-')[0]; // 获取 'en', 'zh' 等
|
const localStorageKey = 'user-locale';
|
||||||
if (navigatorLang === 'zh') {
|
|
||||||
return 'zh';
|
// 尝试从 localStorage 获取语言,否则回退
|
||||||
|
const getInitialLocaleFromStorage = (): 'en' | 'zh' => {
|
||||||
|
const storedLocale = localStorage.getItem(localStorageKey);
|
||||||
|
if (storedLocale === 'en' || storedLocale === 'zh') {
|
||||||
|
return storedLocale;
|
||||||
}
|
}
|
||||||
// 可以添加更多语言支持
|
// Fallback logic (e.g., browser language or default)
|
||||||
return 'en'; // 默认英文
|
const navigatorLang = navigator.language?.split('-')[0];
|
||||||
|
return navigatorLang === 'zh' ? 'zh' : defaultLng;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({
|
const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({
|
||||||
legacy: false, // 必须设置为 false 才能在 Composition API 中使用 useI18n
|
legacy: false, // 必须设置为 false 才能在 Composition API 中使用 useI18n
|
||||||
locale: getInitialLocale(), // 设置初始语言
|
locale: getInitialLocaleFromStorage(), // 使用从 localStorage 或回退获取的初始语言
|
||||||
fallbackLocale: 'en', // 如果当前语言缺少某个 key,则回退到英文
|
fallbackLocale: defaultLng, // 如果当前语言缺少某个 key,则回退到默认语言
|
||||||
messages: {
|
messages: {
|
||||||
en: enMessages,
|
en: enMessages,
|
||||||
zh: zhMessages,
|
zh: zhMessages,
|
||||||
@@ -30,4 +36,23 @@ const i18n = createI18n<[MessageSchema], 'en' | 'zh'>({
|
|||||||
// silentFallbackWarn: true,
|
// 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;
|
export default i18n;
|
||||||
|
|||||||
@@ -342,6 +342,18 @@
|
|||||||
"saveFailed": "Failed to save IP whitelist."
|
"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": {
|
"passkey": {
|
||||||
"title": "Passkey Settings",
|
"title": "Passkey Settings",
|
||||||
"description": "Use Passkeys (biometrics or security keys) for passwordless authentication to enhance security and convenience.",
|
"description": "Use Passkeys (biometrics or security keys) for passwordless authentication to enhance security and convenience.",
|
||||||
@@ -383,7 +395,7 @@
|
|||||||
"webhookMethod": "HTTP Method:",
|
"webhookMethod": "HTTP Method:",
|
||||||
"webhookHeaders": "Custom Headers",
|
"webhookHeaders": "Custom Headers",
|
||||||
"webhookBodyTemplate": "Body Template (Optional)",
|
"webhookBodyTemplate": "Body Template (Optional)",
|
||||||
"webhookBodyPlaceholder": "Default: JSON payload. Use {{event}}, {{timestamp}}, {{details}}.",
|
"webhookBodyPlaceholder": "Default: JSON payload. Use {{{{event}}}}, {{{{timestamp}}}}, {{{{details}}}}.",
|
||||||
"emailTo": "Recipient Email(s):",
|
"emailTo": "Recipient Email(s):",
|
||||||
"emailToHelp": "Comma-separated list.",
|
"emailToHelp": "Comma-separated list.",
|
||||||
"emailSubjectTemplate": "Subject Template (Optional)",
|
"emailSubjectTemplate": "Subject Template (Optional)",
|
||||||
@@ -396,16 +408,17 @@
|
|||||||
"smtpFrom": "Sender Email:",
|
"smtpFrom": "Sender Email:",
|
||||||
"smtpFromHelp": "Email address used in the 'From' field.",
|
"smtpFromHelp": "Email address used in the 'From' field.",
|
||||||
"testButton": "Test Notification",
|
"testButton": "Test Notification",
|
||||||
"testSuccess": "Test email sent successfully!",
|
"testSuccess": "Test notification sent successfully!",
|
||||||
"testFailed": "Test email failed",
|
"testFailed": "Test notification failed",
|
||||||
"saveToTest": "Save the settings before testing.",
|
"saveToTest": "Save the settings before testing.",
|
||||||
|
"fillRequiredToTest": "Fill required fields to enable testing.",
|
||||||
"telegramToken": "Bot Token:",
|
"telegramToken": "Bot Token:",
|
||||||
"telegramTokenHelp": "Store securely. Consider environment variables.",
|
"telegramTokenHelp": "Store securely. Consider environment variables.",
|
||||||
"telegramChatId": "Chat ID:",
|
"telegramChatId": "Chat ID:",
|
||||||
"telegramMessageTemplate": "Message Template (Optional)",
|
"telegramMessageTemplate": "Message Template (Optional)",
|
||||||
"telegramMessagePlaceholder": "Default: Markdown format. Use {{event}}, {{timestamp}}, {{details}}.",
|
"telegramMessagePlaceholder": "Default: Markdown format. Use {{{{event}}}}, {{{{timestamp}}}}, {{{{details}}}}.",
|
||||||
"enabledEvents": "Enabled Events:",
|
"enabledEvents": "Enabled Events:",
|
||||||
"templateHelp": "Placeholders: {{event}}, {{timestamp}}, {{details}} (JSON string)",
|
"templateHelp": "Placeholders: {{{{event}}}}, {{{{timestamp}}}}, {{{{details}}}} (JSON string)",
|
||||||
"invalidJson": "Invalid JSON"
|
"invalidJson": "Invalid JSON"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
@@ -425,6 +438,7 @@
|
|||||||
"API_KEY_DELETED": "API Key Deleted",
|
"API_KEY_DELETED": "API Key Deleted",
|
||||||
"PASSKEY_ADDED": "Passkey Added",
|
"PASSKEY_ADDED": "Passkey Added",
|
||||||
"PASSKEY_DELETED": "Passkey Deleted",
|
"PASSKEY_DELETED": "Passkey Deleted",
|
||||||
|
"IP_BLACKLISTED": "IP Blacklisted",
|
||||||
"SERVER_ERROR": "Server Error"
|
"SERVER_ERROR": "Server Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,18 @@
|
|||||||
"saveFailed": "保存 IP 白名单失败。"
|
"saveFailed": "保存 IP 白名单失败。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "语言设置",
|
||||||
|
"selectLabel": "界面语言:",
|
||||||
|
"saveButton": "保存语言",
|
||||||
|
"success": {
|
||||||
|
"saved": "语言设置已成功保存。"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"fetchFailed": "获取语言设置失败。",
|
||||||
|
"saveFailed": "保存语言设置失败。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"passkey": {
|
"passkey": {
|
||||||
"title": "Passkey 设置",
|
"title": "Passkey 设置",
|
||||||
"description": "使用 Passkey(生物识别或安全密钥)进行无密码认证,提升账户安全性和登录便捷性。",
|
"description": "使用 Passkey(生物识别或安全密钥)进行无密码认证,提升账户安全性和登录便捷性。",
|
||||||
@@ -400,9 +412,10 @@
|
|||||||
"smtpFrom": "发件人邮箱:",
|
"smtpFrom": "发件人邮箱:",
|
||||||
"smtpFromHelp": "用于邮件 'From' 字段的地址。",
|
"smtpFromHelp": "用于邮件 'From' 字段的地址。",
|
||||||
"testButton": "测试通知",
|
"testButton": "测试通知",
|
||||||
"testSuccess": "测试邮件发送成功!",
|
"testSuccess": "测试通知发送成功!",
|
||||||
"testFailed": "测试邮件发送失败",
|
"testFailed": "测试通知发送失败",
|
||||||
"saveToTest": "请先保存设置再进行测试。",
|
"saveToTest": "请先保存设置再进行测试。",
|
||||||
|
"fillRequiredToTest": "请填写必填字段以启用测试。",
|
||||||
"telegramToken": "机器人 Token:",
|
"telegramToken": "机器人 Token:",
|
||||||
"telegramTokenHelp": "请安全存储。建议使用环境变量。",
|
"telegramTokenHelp": "请安全存储。建议使用环境变量。",
|
||||||
"telegramChatId": "聊天 ID:",
|
"telegramChatId": "聊天 ID:",
|
||||||
@@ -429,6 +442,7 @@
|
|||||||
"API_KEY_DELETED": "API 密钥已删除",
|
"API_KEY_DELETED": "API 密钥已删除",
|
||||||
"PASSKEY_ADDED": "Passkey 已添加",
|
"PASSKEY_ADDED": "Passkey 已添加",
|
||||||
"PASSKEY_DELETED": "Passkey 已删除",
|
"PASSKEY_DELETED": "Passkey 已删除",
|
||||||
|
"IP_BLACKLISTED": "IP 已被拉黑",
|
||||||
"SERVER_ERROR": "服务器错误"
|
"SERVER_ERROR": "服务器错误"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; // 引入
|
|||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router'; // 引入我们创建的 router
|
import router from './router'; // 引入我们创建的 router
|
||||||
import i18n from './i18n'; // 引入 i18n 实例
|
import i18n from './i18n'; // 引入 i18n 实例
|
||||||
|
import { useSettingsStore } from './stores/settings.store'; // 引入 Settings Store
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
const pinia = createPinia(); // 创建 Pinia 实例
|
const pinia = createPinia(); // 创建 Pinia 实例
|
||||||
@@ -15,4 +16,12 @@ app.use(pinia); // 使用配置好的 Pinia 实例
|
|||||||
app.use(router); // 使用 Router
|
app.use(router); // 使用 Router
|
||||||
app.use(i18n); // 使用 i18n
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import axios from 'axios'; // Assuming axios is globally available or installed
|
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', () => {
|
export const useNotificationsStore = defineStore('notifications', () => {
|
||||||
const settings = ref<NotificationSetting[]>([]);
|
const settings = ref<NotificationSetting[]>([]);
|
||||||
@@ -94,6 +94,19 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|||||||
// No finally block needed here as loading state is managed in the component
|
// 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)
|
// Computed property to get settings by type (example)
|
||||||
const webhookSettings = computed(() => settings.value.filter(s => s.channel_type === 'webhook'));
|
const webhookSettings = computed(() => settings.value.filter(s => s.channel_type === 'webhook'));
|
||||||
@@ -108,7 +121,8 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|||||||
addSetting,
|
addSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
deleteSetting,
|
deleteSetting,
|
||||||
testSetting, // Add the new function here
|
testSetting,
|
||||||
|
testUnsavedSetting, // Add the new function here
|
||||||
webhookSettings,
|
webhookSettings,
|
||||||
emailSettings,
|
emailSettings,
|
||||||
telegramSettings,
|
telegramSettings,
|
||||||
|
|||||||
@@ -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<Partial<SettingsState>>({}); // Use Partial initially
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const error = ref<string | null>(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<Record<string, string>>('/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<SettingsState>) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ export type NotificationEvent =
|
|||||||
| 'API_KEY_DELETED'
|
| 'API_KEY_DELETED'
|
||||||
| 'PASSKEY_ADDED'
|
| 'PASSKEY_ADDED'
|
||||||
| 'PASSKEY_DELETED'
|
| 'PASSKEY_DELETED'
|
||||||
|
| 'IP_BLACKLISTED' // Add the new event type here as well
|
||||||
| 'SERVER_ERROR';
|
| 'SERVER_ERROR';
|
||||||
|
|
||||||
export interface WebhookConfig {
|
export interface WebhookConfig {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<div class="settings-view">
|
<div class="settings-view">
|
||||||
<h1>{{ $t('settings.title') }}</h1>
|
<h1>{{ $t('settings.title') }}</h1>
|
||||||
|
|
||||||
|
<!-- General Settings Loading/Error -->
|
||||||
|
<div v-if="settingsLoading" class="loading-message">{{ $t('common.loading') }}</div>
|
||||||
|
<div v-if="settingsError" class="error-message">{{ settingsError }}</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>{{ $t('settings.changePassword.title') }}</h2>
|
<h2>{{ $t('settings.changePassword.title') }}</h2>
|
||||||
<form @submit.prevent="handleChangePassword">
|
<form @submit.prevent="handleChangePassword">
|
||||||
@@ -22,11 +26,25 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 其他设置项可以在这里添加 -->
|
<!-- 语言设置 -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>{{ $t('settings.language.title') }}</h2>
|
||||||
|
<form @submit.prevent="handleUpdateLanguage">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="languageSelect">{{ $t('settings.language.selectLabel') }}</label>
|
||||||
|
<select id="languageSelect" v-model="selectedLanguage" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" :disabled="languageLoading">{{ languageLoading ? $t('common.saving') : $t('settings.language.saveButton') }}</button>
|
||||||
|
<p v-if="languageMessage" :class="{ 'success-message': languageSuccess, 'error-message': !languageSuccess }">{{ languageMessage }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>Passkey 设置</h2>
|
<h2>Passkey 设置</h2>
|
||||||
<p>使用 Passkey(无密码认证)提升安全性和便捷性。您可以注册新的 Passkey 用于登录。</p>
|
<p>使用 Passkey(无密码认证)提升安全性和便捷性。您可以注册新的 Passkey 用于登录。</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -92,13 +110,11 @@
|
|||||||
<textarea id="ipWhitelist" v-model="ipWhitelistInput" rows="5"></textarea>
|
<textarea id="ipWhitelist" v-model="ipWhitelistInput" rows="5"></textarea>
|
||||||
<small>{{ $t('settings.ipWhitelist.hint') }}</small>
|
<small>{{ $t('settings.ipWhitelist.hint') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" :disabled="ipWhitelistLoading">{{ ipWhitelistLoading ? $t('common.loading') : $t('settings.ipWhitelist.saveButton') }}</button>
|
<button type="submit" :disabled="ipWhitelistLoading">{{ ipWhitelistLoading ? $t('common.saving') : $t('settings.ipWhitelist.saveButton') }}</button>
|
||||||
<p v-if="ipWhitelistMessage" :class="{ 'success-message': ipWhitelistSuccess, 'error-message': !ipWhitelistSuccess }">{{ ipWhitelistMessage }}</p>
|
<p v-if="ipWhitelistMessage" :class="{ 'success-message': ipWhitelistSuccess, 'error-message': !ipWhitelistSuccess }">{{ ipWhitelistMessage }}</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 其他设置项可以在这里添加 -->
|
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<!-- IP 黑名单管理 -->
|
<!-- IP 黑名单管理 -->
|
||||||
@@ -110,14 +126,14 @@
|
|||||||
<form @submit.prevent="handleUpdateBlacklistSettings" class="blacklist-settings-form">
|
<form @submit.prevent="handleUpdateBlacklistSettings" class="blacklist-settings-form">
|
||||||
<div class="form-group inline-group">
|
<div class="form-group inline-group">
|
||||||
<label for="maxLoginAttempts">最大失败次数:</label>
|
<label for="maxLoginAttempts">最大失败次数:</label>
|
||||||
<input type="number" id="maxLoginAttempts" v-model="blacklistSettings.maxLoginAttempts" min="1" required>
|
<input type="number" id="maxLoginAttempts" v-model="blacklistSettingsForm.maxLoginAttempts" min="1" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group inline-group">
|
<div class="form-group inline-group">
|
||||||
<label for="loginBanDuration">封禁时长 (秒):</label>
|
<label for="loginBanDuration">封禁时长 (秒):</label>
|
||||||
<input type="number" id="loginBanDuration" v-model="blacklistSettings.loginBanDuration" min="1" required>
|
<input type="number" id="loginBanDuration" v-model="blacklistSettingsForm.loginBanDuration" min="1" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" :disabled="blacklistSettings.loading">{{ blacklistSettings.loading ? '保存中...' : '保存配置' }}</button>
|
<button type="submit" :disabled="blacklistSettingsLoading">{{ blacklistSettingsLoading ? $t('common.saving') : '保存配置' }}</button>
|
||||||
<p v-if="blacklistSettings.message" :class="{ 'success-message': blacklistSettings.success, 'error-message': !blacklistSettings.success }">{{ blacklistSettings.message }}</p>
|
<p v-if="blacklistSettingsMessage" :class="{ 'success-message': blacklistSettingsSuccess, 'error-message': !blacklistSettingsSuccess }">{{ blacklistSettingsMessage }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr style="margin-top: 20px; margin-bottom: 20px;">
|
<hr style="margin-top: 20px; margin-bottom: 20px;">
|
||||||
@@ -156,15 +172,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-else>当前没有 IP 地址在黑名单中。</p>
|
<p v-else>当前没有 IP 地址在黑名单中。</p>
|
||||||
|
|
||||||
<!-- 分页控件 (如果需要) -->
|
|
||||||
<!--
|
|
||||||
<div class="pagination" v-if="ipBlacklist.total > ipBlacklist.limit">
|
|
||||||
<button @click="fetchIpBlacklist(ipBlacklist.currentPage - 1)" :disabled="ipBlacklist.currentPage <= 1">上一页</button>
|
|
||||||
<span>第 {{ ipBlacklist.currentPage }} 页 / 共 {{ Math.ceil(ipBlacklist.total / ipBlacklist.limit) }} 页</span>
|
|
||||||
<button @click="fetchIpBlacklist(ipBlacklist.currentPage + 1)" :disabled="ipBlacklist.currentPage * ipBlacklist.limit >= ipBlacklist.total">下一页</button>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
<p v-if="blacklistDeleteError" class="error-message">{{ blacklistDeleteError }}</p>
|
<p v-if="blacklistDeleteError" class="error-message">{{ blacklistDeleteError }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,168 +180,97 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, reactive } from 'vue'; // 导入 computed 和 reactive
|
import { ref, onMounted, computed, reactive, watch, toRefs } from 'vue';
|
||||||
import { useAuthStore } from '../stores/auth.store';
|
import { useAuthStore } from '../stores/auth.store';
|
||||||
|
import { useSettingsStore } from '../stores/settings.store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import axios from 'axios'; // 导入 axios
|
// setLocale is handled by the store now
|
||||||
import { startRegistration } from '@simplewebauthn/browser'; // 导入 simplewebauthn
|
import axios from 'axios';
|
||||||
// import NotificationSettings from '../components/NotificationSettings.vue'; // 确认移除或根据需要取消注释
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
// --- Passkey 相关状态与方法 ---
|
// --- Reactive state from store ---
|
||||||
const passkeyName = ref(''); // 新增 Passkey 名称 ref
|
const { settings, isLoading: settingsLoading, error: settingsError } = toRefs(settingsStore);
|
||||||
const passkeyMessage = ref<string | null>(null); // 用于显示 Passkey 相关消息
|
|
||||||
const passkeyError = ref<string | null>(null); // 用于显示 Passkey 相关错误
|
|
||||||
|
|
||||||
|
// --- Local state for forms ---
|
||||||
|
const ipWhitelistInput = ref('');
|
||||||
|
const selectedLanguage = ref<'en' | 'zh'>('en'); // Default to 'en', will be updated by watcher
|
||||||
|
const blacklistSettingsForm = reactive({ // Renamed to avoid conflict with store state name
|
||||||
|
maxLoginAttempts: '5',
|
||||||
|
loginBanDuration: '300',
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Local UI feedback state ---
|
||||||
|
const ipWhitelistLoading = ref(false);
|
||||||
|
const ipWhitelistMessage = ref('');
|
||||||
|
const ipWhitelistSuccess = ref(false);
|
||||||
|
const languageLoading = ref(false);
|
||||||
|
const languageMessage = ref('');
|
||||||
|
const languageSuccess = ref(false);
|
||||||
|
const blacklistSettingsLoading = ref(false);
|
||||||
|
const blacklistSettingsMessage = ref('');
|
||||||
|
const blacklistSettingsSuccess = ref(false);
|
||||||
|
|
||||||
|
// --- Watcher to sync local form state with store state ---
|
||||||
|
watch(settings, (newSettings) => {
|
||||||
|
ipWhitelistInput.value = newSettings.ipWhitelist || '';
|
||||||
|
selectedLanguage.value = newSettings.language || 'en';
|
||||||
|
blacklistSettingsForm.maxLoginAttempts = newSettings.maxLoginAttempts || '5';
|
||||||
|
blacklistSettingsForm.loginBanDuration = newSettings.loginBanDuration || '300';
|
||||||
|
}, { deep: true, immediate: true }); // immediate: true to run on initial load
|
||||||
|
|
||||||
|
// --- Passkey state & methods --- (Keep as is)
|
||||||
|
const passkeyName = ref('');
|
||||||
|
const passkeyMessage = ref<string | null>(null);
|
||||||
|
const passkeyError = ref<string | null>(null);
|
||||||
const handleRegisterPasskey = async () => {
|
const handleRegisterPasskey = async () => {
|
||||||
passkeyMessage.value = null;
|
passkeyMessage.value = null;
|
||||||
passkeyError.value = null;
|
passkeyError.value = null;
|
||||||
|
|
||||||
if (!passkeyName.value) {
|
if (!passkeyName.value) {
|
||||||
passkeyError.value = t('settings.passkey.error.nameRequired'); // 使用 t()
|
passkeyError.value = t('settings.passkey.error.nameRequired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 获取注册选项
|
|
||||||
const optionsResponse = await axios.post('/api/v1/auth/passkey/register-options');
|
const optionsResponse = await axios.post('/api/v1/auth/passkey/register-options');
|
||||||
const options = optionsResponse.data;
|
const options = optionsResponse.data;
|
||||||
|
let registrationResponse = await startRegistration(options);
|
||||||
// 2. 调用 WebAuthn API 发起注册
|
await axios.post('/api/v1/auth/passkey/verify-registration', { registrationResponse, name: passkeyName.value });
|
||||||
let registrationResponse;
|
passkeyMessage.value = t('settings.passkey.success.registered');
|
||||||
try {
|
passkeyName.value = '';
|
||||||
registrationResponse = await startRegistration(options);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Passkey 注册被取消或失败:', error);
|
|
||||||
// 根据错误类型提供更具体的提示
|
|
||||||
if (error.name === 'NotAllowedError') {
|
|
||||||
passkeyError.value = t('settings.passkey.error.cancelled'); // 使用 t()
|
|
||||||
} else {
|
|
||||||
passkeyError.value = t('settings.passkey.error.genericRegistration', { message: error.message || 'Unknown error' }); // 使用 t()
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 提交注册响应到后端验证
|
|
||||||
await axios.post('/api/v1/auth/passkey/verify-registration', {
|
|
||||||
registrationResponse,
|
|
||||||
name: passkeyName.value // 提交 Passkey 名称
|
|
||||||
});
|
|
||||||
|
|
||||||
passkeyMessage.value = t('settings.passkey.success.registered'); // 使用 t()
|
|
||||||
passkeyName.value = ''; // 清空输入框
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Passkey 注册流程出错:', error);
|
console.error('Passkey 注册流程出错:', error);
|
||||||
if (axios.isAxiosError(error) && error.response) {
|
if (error.name === 'NotAllowedError') {
|
||||||
passkeyError.value = t('settings.passkey.error.verificationFailed', { message: error.response.data.message || 'Server error' }); // 使用 t()
|
passkeyError.value = t('settings.passkey.error.cancelled');
|
||||||
|
} else if (axios.isAxiosError(error) && error.response) {
|
||||||
|
passkeyError.value = t('settings.passkey.error.verificationFailed', { message: error.response.data.message || 'Server error' });
|
||||||
} else {
|
} else {
|
||||||
passkeyError.value = t('settings.passkey.error.unknown'); // 使用 t()
|
passkeyError.value = t('settings.passkey.error.genericRegistration', { message: error.message || t('settings.passkey.error.unknown') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 修改密码状态 ---
|
// --- Change Password state & methods --- (Keep as is)
|
||||||
const currentPassword = ref('');
|
const currentPassword = ref('');
|
||||||
const newPassword = ref('');
|
const newPassword = ref('');
|
||||||
const confirmPassword = ref('');
|
const confirmPassword = ref('');
|
||||||
const changePasswordLoading = ref(false);
|
const changePasswordLoading = ref(false);
|
||||||
const changePasswordMessage = ref('');
|
const changePasswordMessage = ref('');
|
||||||
const changePasswordSuccess = ref(false);
|
const changePasswordSuccess = ref(false);
|
||||||
|
|
||||||
// --- 2FA 状态 ---
|
|
||||||
const twoFactorEnabled = ref(false); // 用户当前的 2FA 状态
|
|
||||||
const twoFactorLoading = ref(false);
|
|
||||||
const twoFactorMessage = ref('');
|
|
||||||
const twoFactorSuccess = ref(false);
|
|
||||||
const setupData = ref<{ secret: string; qrCodeUrl: string } | null>(null); // 存储设置密钥和二维码
|
|
||||||
const verificationCode = ref(''); // 用户输入的验证码
|
|
||||||
const disablePassword = ref(''); // 禁用时需要输入的密码
|
|
||||||
|
|
||||||
// --- IP 白名单状态 ---
|
|
||||||
const ipWhitelistInput = ref(''); // 用于编辑的文本区域内容
|
|
||||||
const ipWhitelistLoading = ref(false);
|
|
||||||
const ipWhitelistMessage = ref('');
|
|
||||||
const ipWhitelistSuccess = ref(false);
|
|
||||||
|
|
||||||
// --- IP 黑名单状态 ---
|
|
||||||
const ipBlacklist = reactive({
|
|
||||||
entries: [] as any[], // TODO: Define proper type
|
|
||||||
total: 0,
|
|
||||||
loading: false,
|
|
||||||
error: null as string | null,
|
|
||||||
currentPage: 1,
|
|
||||||
limit: 10, // 每页显示数量
|
|
||||||
});
|
|
||||||
const blacklistToDeleteIp = ref<string | null>(null); // 存储待确认删除的 IP
|
|
||||||
const blacklistDeleteLoading = ref(false);
|
|
||||||
const blacklistDeleteError = ref<string | null>(null);
|
|
||||||
|
|
||||||
// --- 黑名单配置状态 ---
|
|
||||||
const blacklistSettings = reactive({
|
|
||||||
maxLoginAttempts: '5', // 默认值
|
|
||||||
loginBanDuration: '300', // 默认值 (秒)
|
|
||||||
loading: false,
|
|
||||||
message: '',
|
|
||||||
success: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// 计算属性判断当前是否处于 2FA 设置流程中
|
|
||||||
const isSettingUp2FA = computed(() => setupData.value !== null);
|
|
||||||
|
|
||||||
// 获取当前用户的 2FA 状态 (理想情况下后端应提供接口,这里暂时假设从 authStore 或其他地方获取)
|
|
||||||
const checkTwoFactorStatus = async () => {
|
|
||||||
// 调用 store action 获取最新状态
|
|
||||||
await authStore.checkAuthStatus();
|
|
||||||
// 从 store 更新本地状态
|
|
||||||
twoFactorEnabled.value = authStore.user?.isTwoFactorEnabled ?? false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取当前的 IP 白名单设置
|
|
||||||
const fetchIpWhitelist = async () => {
|
|
||||||
ipWhitelistLoading.value = true;
|
|
||||||
ipWhitelistMessage.value = '';
|
|
||||||
try {
|
|
||||||
// 使用 settings API 获取所有设置
|
|
||||||
const response = await axios.get<Record<string, string>>('/api/v1/settings');
|
|
||||||
ipWhitelistInput.value = response.data['ipWhitelist'] || ''; // 从设置中获取,默认为空字符串
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('获取 IP 白名单设置失败:', error);
|
|
||||||
ipWhitelistMessage.value = t('settings.ipWhitelist.error.fetchFailed');
|
|
||||||
ipWhitelistSuccess.value = false;
|
|
||||||
} finally {
|
|
||||||
ipWhitelistLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- 生命周期钩子 ---
|
|
||||||
onMounted(async () => { // 使 onMounted 异步
|
|
||||||
await checkTwoFactorStatus(); // 等待状态检查完成
|
|
||||||
await fetchIpWhitelist(); // 获取 IP 白名单设置
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 修改密码 ---
|
|
||||||
const handleChangePassword = async () => {
|
const handleChangePassword = async () => {
|
||||||
changePasswordMessage.value = ''; // 清除之前的消息
|
changePasswordMessage.value = '';
|
||||||
changePasswordSuccess.value = false;
|
changePasswordSuccess.value = false;
|
||||||
|
|
||||||
if (newPassword.value !== confirmPassword.value) {
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
changePasswordMessage.value = t('settings.changePassword.error.passwordsDoNotMatch');
|
changePasswordMessage.value = t('settings.changePassword.error.passwordsDoNotMatch');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 可选:添加前端密码复杂度校验
|
|
||||||
// 可选:添加前端密码复杂度校验
|
|
||||||
|
|
||||||
changePasswordLoading.value = true;
|
changePasswordLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await authStore.changePassword(currentPassword.value, newPassword.value);
|
await authStore.changePassword(currentPassword.value, newPassword.value);
|
||||||
changePasswordMessage.value = t('settings.changePassword.success');
|
changePasswordMessage.value = t('settings.changePassword.success');
|
||||||
changePasswordSuccess.value = true;
|
changePasswordSuccess.value = true;
|
||||||
// 清空表单
|
|
||||||
currentPassword.value = '';
|
currentPassword.value = '';
|
||||||
newPassword.value = '';
|
newPassword.value = '';
|
||||||
confirmPassword.value = '';
|
confirmPassword.value = '';
|
||||||
@@ -347,112 +283,119 @@ const handleChangePassword = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 2FA 相关方法 ---
|
// --- 2FA state & methods --- (Keep as is)
|
||||||
|
const twoFactorEnabled = ref(false);
|
||||||
// 开始设置 2FA
|
const twoFactorLoading = ref(false);
|
||||||
|
const twoFactorMessage = ref('');
|
||||||
|
const twoFactorSuccess = ref(false);
|
||||||
|
const setupData = ref<{ secret: string; qrCodeUrl: string } | null>(null);
|
||||||
|
const verificationCode = ref('');
|
||||||
|
const disablePassword = ref('');
|
||||||
|
const isSettingUp2FA = computed(() => setupData.value !== null);
|
||||||
|
const checkTwoFactorStatus = async () => {
|
||||||
|
await authStore.checkAuthStatus();
|
||||||
|
twoFactorEnabled.value = authStore.user?.isTwoFactorEnabled ?? false;
|
||||||
|
};
|
||||||
const handleSetup2FA = async () => {
|
const handleSetup2FA = async () => {
|
||||||
twoFactorMessage.value = '';
|
twoFactorMessage.value = ''; twoFactorSuccess.value = false; twoFactorLoading.value = true;
|
||||||
twoFactorSuccess.value = false;
|
setupData.value = null; verificationCode.value = '';
|
||||||
twoFactorLoading.value = true;
|
|
||||||
setupData.value = null; // 清除旧数据
|
|
||||||
verificationCode.value = ''; // 清除验证码
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<{ secret: string; qrCodeUrl: string }>('/api/v1/auth/2fa/setup');
|
const response = await axios.post<{ secret: string; qrCodeUrl: string }>('/api/v1/auth/2fa/setup');
|
||||||
setupData.value = response.data;
|
setupData.value = response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('开始设置 2FA 失败:', error);
|
console.error('开始设置 2FA 失败:', error);
|
||||||
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.setupFailed');
|
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.setupFailed');
|
||||||
} finally {
|
} finally { twoFactorLoading.value = false; }
|
||||||
twoFactorLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 验证并激活 2FA
|
|
||||||
const handleVerifyAndActivate2FA = async () => {
|
const handleVerifyAndActivate2FA = async () => {
|
||||||
if (!setupData.value || !verificationCode.value) {
|
if (!setupData.value || !verificationCode.value) {
|
||||||
twoFactorMessage.value = t('settings.twoFactor.error.codeRequired');
|
twoFactorMessage.value = t('settings.twoFactor.error.codeRequired'); return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
twoFactorMessage.value = ''; twoFactorSuccess.value = false; twoFactorLoading.value = true;
|
||||||
twoFactorMessage.value = '';
|
|
||||||
twoFactorSuccess.value = false;
|
|
||||||
twoFactorLoading.value = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/auth/2fa/verify', { token: verificationCode.value });
|
await axios.post('/api/v1/auth/2fa/verify', { token: verificationCode.value });
|
||||||
twoFactorMessage.value = t('settings.twoFactor.success.activated');
|
twoFactorMessage.value = t('settings.twoFactor.success.activated');
|
||||||
twoFactorSuccess.value = true;
|
twoFactorSuccess.value = true; twoFactorEnabled.value = true;
|
||||||
twoFactorEnabled.value = true; // 更新状态
|
setupData.value = null; verificationCode.value = '';
|
||||||
setupData.value = null; // 清除设置数据
|
|
||||||
verificationCode.value = '';
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('验证并激活 2FA 失败:', error);
|
console.error('验证并激活 2FA 失败:', error);
|
||||||
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.verificationFailed');
|
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.verificationFailed');
|
||||||
} finally {
|
} finally { twoFactorLoading.value = false; }
|
||||||
twoFactorLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 禁用 2FA
|
|
||||||
const handleDisable2FA = async () => {
|
const handleDisable2FA = async () => {
|
||||||
if (!disablePassword.value) {
|
if (!disablePassword.value) {
|
||||||
twoFactorMessage.value = t('settings.twoFactor.error.passwordRequiredForDisable');
|
twoFactorMessage.value = t('settings.twoFactor.error.passwordRequiredForDisable'); return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
twoFactorMessage.value = '';
|
twoFactorMessage.value = ''; twoFactorSuccess.value = false; twoFactorLoading.value = true;
|
||||||
twoFactorSuccess.value = false;
|
|
||||||
twoFactorLoading.value = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete('/api/v1/auth/2fa', { data: { password: disablePassword.value } }); // DELETE 请求体通过 data 发送
|
await axios.delete('/api/v1/auth/2fa', { data: { password: disablePassword.value } });
|
||||||
twoFactorMessage.value = t('settings.twoFactor.success.disabled');
|
twoFactorMessage.value = t('settings.twoFactor.success.disabled');
|
||||||
twoFactorSuccess.value = true;
|
twoFactorSuccess.value = true; twoFactorEnabled.value = false;
|
||||||
twoFactorEnabled.value = false; // 更新状态
|
disablePassword.value = '';
|
||||||
disablePassword.value = ''; // 清空密码
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('禁用 2FA 失败:', error);
|
console.error('禁用 2FA 失败:', error);
|
||||||
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.disableFailed');
|
twoFactorMessage.value = error.response?.data?.message || t('settings.twoFactor.error.disableFailed');
|
||||||
} finally {
|
} finally { twoFactorLoading.value = false; }
|
||||||
twoFactorLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取消设置流程
|
|
||||||
const cancelSetup = () => {
|
const cancelSetup = () => {
|
||||||
setupData.value = null;
|
setupData.value = null; verificationCode.value = ''; twoFactorMessage.value = '';
|
||||||
verificationCode.value = '';
|
|
||||||
twoFactorMessage.value = '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- IP 白名单相关方法 ---
|
// --- Language settings method --- (Refactored)
|
||||||
|
const handleUpdateLanguage = async () => {
|
||||||
|
languageLoading.value = true;
|
||||||
|
languageMessage.value = '';
|
||||||
|
languageSuccess.value = false;
|
||||||
|
try {
|
||||||
|
await settingsStore.updateSetting('language', selectedLanguage.value);
|
||||||
|
languageMessage.value = t('settings.language.success.saved');
|
||||||
|
languageSuccess.value = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('更新语言设置失败:', error);
|
||||||
|
languageMessage.value = error.message || t('settings.language.error.saveFailed');
|
||||||
|
languageSuccess.value = false;
|
||||||
|
} finally {
|
||||||
|
languageLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- IP Whitelist method --- (Refactored)
|
||||||
const handleUpdateIpWhitelist = async () => {
|
const handleUpdateIpWhitelist = async () => {
|
||||||
ipWhitelistLoading.value = true;
|
ipWhitelistLoading.value = true;
|
||||||
ipWhitelistMessage.value = '';
|
ipWhitelistMessage.value = '';
|
||||||
ipWhitelistSuccess.value = false;
|
ipWhitelistSuccess.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调用 settings API 更新设置
|
await settingsStore.updateSetting('ipWhitelist', ipWhitelistInput.value.trim());
|
||||||
await axios.put('/api/v1/settings', {
|
|
||||||
ipWhitelist: ipWhitelistInput.value.trim() // 发送修剪后的值
|
|
||||||
});
|
|
||||||
ipWhitelistMessage.value = t('settings.ipWhitelist.success.saved');
|
ipWhitelistMessage.value = t('settings.ipWhitelist.success.saved');
|
||||||
ipWhitelistSuccess.value = true;
|
ipWhitelistSuccess.value = true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('更新 IP 白名单失败:', error);
|
console.error('更新 IP 白名单失败:', error);
|
||||||
ipWhitelistMessage.value = error.response?.data?.message || t('settings.ipWhitelist.error.saveFailed');
|
ipWhitelistMessage.value = error.message || t('settings.ipWhitelist.error.saveFailed');
|
||||||
ipWhitelistSuccess.value = false;
|
ipWhitelistSuccess.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
ipWhitelistLoading.value = false;
|
ipWhitelistLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- IP 黑名单相关方法 ---
|
// --- IP Blacklist state & methods --- (Keep fetch/delete as is, update uses store)
|
||||||
|
const ipBlacklist = reactive({
|
||||||
|
entries: [] as any[],
|
||||||
|
total: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
currentPage: 1,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
const blacklistToDeleteIp = ref<string | null>(null);
|
||||||
|
const blacklistDeleteLoading = ref(false);
|
||||||
|
const blacklistDeleteError = ref<string | null>(null);
|
||||||
|
|
||||||
const fetchIpBlacklist = async (page = 1) => {
|
const fetchIpBlacklist = async (page = 1) => {
|
||||||
ipBlacklist.loading = true;
|
ipBlacklist.loading = true;
|
||||||
ipBlacklist.error = null;
|
ipBlacklist.error = null;
|
||||||
const offset = (page - 1) * ipBlacklist.limit;
|
const offset = (page - 1) * ipBlacklist.limit;
|
||||||
try {
|
try {
|
||||||
|
// Assuming fetchIpBlacklist is still needed from authStore
|
||||||
const data = await authStore.fetchIpBlacklist(ipBlacklist.limit, offset);
|
const data = await authStore.fetchIpBlacklist(ipBlacklist.limit, offset);
|
||||||
ipBlacklist.entries = data.entries;
|
ipBlacklist.entries = data.entries;
|
||||||
ipBlacklist.total = data.total;
|
ipBlacklist.total = data.total;
|
||||||
@@ -465,84 +408,61 @@ const fetchIpBlacklist = async (page = 1) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteIp = async (ip: string) => {
|
const handleDeleteIp = async (ip: string) => {
|
||||||
blacklistToDeleteIp.value = ip; // 设置待确认的 IP
|
blacklistToDeleteIp.value = ip;
|
||||||
// 可以在这里添加一个确认对话框
|
|
||||||
if (confirm(`确定要从黑名单中移除 IP 地址 "${ip}" 吗?`)) {
|
if (confirm(`确定要从黑名单中移除 IP 地址 "${ip}" 吗?`)) {
|
||||||
blacklistDeleteLoading.value = true;
|
blacklistDeleteLoading.value = true;
|
||||||
blacklistDeleteError.value = null;
|
blacklistDeleteError.value = null;
|
||||||
try {
|
try {
|
||||||
|
// Assuming deleteIpFromBlacklist is still needed from authStore
|
||||||
await authStore.deleteIpFromBlacklist(ip);
|
await authStore.deleteIpFromBlacklist(ip);
|
||||||
// 成功后刷新列表
|
|
||||||
await fetchIpBlacklist(ipBlacklist.currentPage);
|
await fetchIpBlacklist(ipBlacklist.currentPage);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
blacklistDeleteError.value = error.message || '删除失败';
|
blacklistDeleteError.value = error.message || '删除失败';
|
||||||
} finally {
|
} finally {
|
||||||
blacklistDeleteLoading.value = false;
|
blacklistDeleteLoading.value = false;
|
||||||
blacklistToDeleteIp.value = null; // 清除待确认 IP
|
blacklistToDeleteIp.value = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
blacklistToDeleteIp.value = null; // 用户取消,清除待确认 IP
|
blacklistToDeleteIp.value = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取黑名单配置
|
// Update Blacklist Config method (Refactored)
|
||||||
const fetchBlacklistSettings = async () => {
|
|
||||||
blacklistSettings.loading = true;
|
|
||||||
blacklistSettings.message = '';
|
|
||||||
try {
|
|
||||||
const response = await axios.get<Record<string, string>>('/api/v1/settings');
|
|
||||||
blacklistSettings.maxLoginAttempts = response.data['maxLoginAttempts'] || '5';
|
|
||||||
blacklistSettings.loginBanDuration = response.data['loginBanDuration'] || '300';
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('获取黑名单配置失败:', error);
|
|
||||||
blacklistSettings.message = '获取黑名单配置失败';
|
|
||||||
blacklistSettings.success = false;
|
|
||||||
} finally {
|
|
||||||
blacklistSettings.loading = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新黑名单配置
|
|
||||||
const handleUpdateBlacklistSettings = async () => {
|
const handleUpdateBlacklistSettings = async () => {
|
||||||
blacklistSettings.loading = true;
|
blacklistSettingsLoading.value = true;
|
||||||
blacklistSettings.message = '';
|
blacklistSettingsMessage.value = '';
|
||||||
blacklistSettings.success = false;
|
blacklistSettingsSuccess.value = false;
|
||||||
try {
|
try {
|
||||||
// 验证输入是否为有效数字
|
const maxAttempts = parseInt(blacklistSettingsForm.maxLoginAttempts, 10);
|
||||||
const maxAttempts = parseInt(blacklistSettings.maxLoginAttempts, 10);
|
const banDuration = parseInt(blacklistSettingsForm.loginBanDuration, 10);
|
||||||
const banDuration = parseInt(blacklistSettings.loginBanDuration, 10);
|
|
||||||
if (isNaN(maxAttempts) || maxAttempts <= 0) {
|
if (isNaN(maxAttempts) || maxAttempts <= 0) {
|
||||||
throw new Error('最大失败次数必须是正整数。');
|
throw new Error('最大失败次数必须是正整数。');
|
||||||
}
|
}
|
||||||
if (isNaN(banDuration) || banDuration <= 0) {
|
if (isNaN(banDuration) || banDuration <= 0) {
|
||||||
throw new Error('封禁时长必须是正整数(秒)。');
|
throw new Error('封禁时长必须是正整数(秒)。');
|
||||||
}
|
}
|
||||||
|
await settingsStore.updateMultipleSettings({
|
||||||
await axios.put('/api/v1/settings', {
|
maxLoginAttempts: blacklistSettingsForm.maxLoginAttempts,
|
||||||
maxLoginAttempts: blacklistSettings.maxLoginAttempts,
|
loginBanDuration: blacklistSettingsForm.loginBanDuration,
|
||||||
loginBanDuration: blacklistSettings.loginBanDuration,
|
|
||||||
});
|
});
|
||||||
blacklistSettings.message = '黑名单配置已成功更新。';
|
blacklistSettingsMessage.value = '黑名单配置已成功更新。';
|
||||||
blacklistSettings.success = true;
|
blacklistSettingsSuccess.value = true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('更新黑名单配置失败:', error);
|
console.error('更新黑名单配置失败:', error);
|
||||||
blacklistSettings.message = error.message || '更新黑名单配置失败';
|
blacklistSettingsMessage.value = error.message || '更新黑名单配置失败';
|
||||||
blacklistSettings.success = false;
|
blacklistSettingsSuccess.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
blacklistSettings.loading = false;
|
blacklistSettingsLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Lifecycle Hooks ---
|
||||||
// 在 onMounted 中调用 fetchIpBlacklist 和 fetchBlacklistSettings
|
onMounted(async () => {
|
||||||
onMounted(async () => { // 使 onMounted 异步
|
await checkTwoFactorStatus(); // Check 2FA status
|
||||||
await checkTwoFactorStatus(); // 等待状态检查完成
|
await fetchIpBlacklist(); // Fetch current blacklist entries
|
||||||
await fetchIpWhitelist(); // 获取 IP 白名单设置
|
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
|
||||||
await fetchIpBlacklist(); // 获取 IP 黑名单列表
|
|
||||||
await fetchBlacklistSettings(); // 获取黑名单配置
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -568,22 +488,23 @@ label {
|
|||||||
|
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
textarea { /* 添加 textarea 样式 */
|
textarea,
|
||||||
|
select { /* Add select style */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid #ccc; /* 确保 textarea 有边框 */
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px; /* 确保 textarea 有圆角 */
|
border-radius: 4px;
|
||||||
font-family: inherit; /* 继承字体 */
|
font-family: inherit;
|
||||||
font-size: inherit; /* 继承字号 */
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
resize: vertical; /* 允许垂直调整大小 */
|
resize: vertical;
|
||||||
min-height: 80px; /* 设置最小高度 */
|
min-height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
small { /* 提示文字样式 */
|
small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
@@ -617,7 +538,7 @@ code {
|
|||||||
img {
|
img {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
max-width: 200px; /* 限制二维码大小 */
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-message {
|
.success-message {
|
||||||
@@ -687,25 +608,25 @@ img {
|
|||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
}
|
}
|
||||||
.blacklist-settings-form .inline-group {
|
.blacklist-settings-form .inline-group {
|
||||||
display: inline-block; /* 让 label 和 input 在一行显示 */
|
display: inline-block;
|
||||||
margin-right: 20px; /* 组之间的间距 */
|
margin-right: 20px;
|
||||||
margin-bottom: 10px; /* 增加底部间距 */
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.blacklist-settings-form .inline-group label {
|
.blacklist-settings-form .inline-group label {
|
||||||
display: inline-block; /* 行内块 */
|
display: inline-block;
|
||||||
margin-right: 5px; /* label 和 input 之间的间距 */
|
margin-right: 5px;
|
||||||
width: auto; /* 覆盖默认的 block 宽度 */
|
width: auto;
|
||||||
margin-bottom: 0; /* 移除默认的底部间距 */
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.blacklist-settings-form .inline-group input[type="number"] {
|
.blacklist-settings-form .inline-group input[type="number"] {
|
||||||
width: 80px; /* 设置一个合适的宽度 */
|
width: 80px;
|
||||||
display: inline-block; /* 行内块 */
|
display: inline-block;
|
||||||
padding: 6px; /* 调整内边距 */
|
padding: 6px;
|
||||||
}
|
}
|
||||||
.blacklist-settings-form button {
|
.blacklist-settings-form button {
|
||||||
vertical-align: bottom; /* 对齐按钮和输入框 */
|
vertical-align: bottom;
|
||||||
}
|
}
|
||||||
.blacklist-settings-form p { /* 消息样式 */
|
.blacklist-settings-form p {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user