feat: 远端备份增加 Telegram 备份源
This commit is contained in:
1 parent
bfc3f089a8
commit
e6a86b2050
20 files changed
+491
-67
No files matched your search
@@ -46,7 +46,7 @@
|
||||
- 🛡️ **TOTP 密钥分析与生成工具**
|
||||
|
||||
### 🔄 企业级数据灾备
|
||||
- **WebDAV / S3 自动备份**:连接你的Nextcloud、阿里云 OSS 或 AWS S3。
|
||||
- **WebDAV / S3 / Telegram 自动备份**:连接你的Nextcloud、阿里云 OSS 或 AWS S3,或者 Telegram Bot。
|
||||
- 自动化定时任务(Cron Triggers)实现无感数据双重灾备。
|
||||
|
||||
---
|
||||
|
||||
+13
-1
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS vault (
|
||||
-- 云端备份源配置表
|
||||
CREATE TABLE IF NOT EXISTS backup_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL, -- 类型: 'webdav', 's3'
|
||||
type TEXT NOT NULL, -- 类型: 'webdav', 's3', 'telegram'
|
||||
name TEXT NOT NULL, -- 显示名称
|
||||
is_enabled BOOLEAN DEFAULT 1, -- 启用状态
|
||||
config TEXT NOT NULL, -- 配置 JSON (敏感字段加密)
|
||||
@@ -30,11 +30,23 @@ CREATE TABLE IF NOT EXISTS backup_providers (
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
-- Telegram 备份历史记录表
|
||||
CREATE TABLE IF NOT EXISTS backup_telegram_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 索引
|
||||
DROP INDEX IF EXISTS idx_vault_service;
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_providers_type ON backup_providers(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_vault_service_created_at ON vault(service, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_telegram_history_provider_id ON backup_telegram_history(provider_id, created_at DESC);
|
||||
|
||||
-- Remove any existing duplicates before enforcing unique constraint; keep the earliest
|
||||
DELETE FROM vault
|
||||
|
||||
@@ -65,6 +65,14 @@ backups.post('/providers/:id/download', async (c) => {
|
||||
return c.json({ success: true, content });
|
||||
});
|
||||
|
||||
backups.post('/providers/:id/files/delete', async (c) => {
|
||||
const service = new BackupService(c.env);
|
||||
const id = Number(c.req.param('id'));
|
||||
const { filename } = await c.req.json();
|
||||
await service.deleteFile(id, filename);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
export async function handleScheduledBackup(env: EnvBindings) {
|
||||
const service = new BackupService(env);
|
||||
await service.handleScheduledBackup();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { eq, desc } from 'drizzle-orm';
|
||||
import { EnvBindings, AppError } from '@/app/config';
|
||||
import { BackupRepository } from '@/shared/db/repositories/backupRepository';
|
||||
import { encryptData, decryptData, encryptBackupFile } from '@/shared/utils/crypto';
|
||||
import { BackupProvider, WebDavProvider, S3Provider } from '@/features/backup/providers';
|
||||
import { BackupProvider, WebDavProvider, S3Provider, TelegramProvider } from '@/features/backup/providers';
|
||||
import { createDb, decryptField } from '@/shared/db/db';
|
||||
import { vault as vaultTable, backupProviders } from '@/shared/db/schema';
|
||||
|
||||
@@ -17,12 +17,14 @@ export class BackupService {
|
||||
this.repository = new BackupRepository(env.DB);
|
||||
}
|
||||
|
||||
private async getProvider(type: string, config: any): Promise<BackupProvider> {
|
||||
private async getProvider(type: string, config: any, id?: number): Promise<BackupProvider> {
|
||||
switch (type) {
|
||||
case 'webdav':
|
||||
return new WebDavProvider(config);
|
||||
case 's3':
|
||||
return new S3Provider(config);
|
||||
case 'telegram':
|
||||
return new TelegramProvider(config, this.db, id);
|
||||
default:
|
||||
throw new AppError('provider_not_found', 400);
|
||||
}
|
||||
@@ -36,6 +38,9 @@ export class BackupService {
|
||||
if (type === 's3' && processed.secretAccessKey) {
|
||||
processed.secretAccessKey = await encryptData(processed.secretAccessKey, key);
|
||||
}
|
||||
if (type === 'telegram' && processed.botToken) {
|
||||
processed.botToken = await encryptData(processed.botToken, key);
|
||||
}
|
||||
return JSON.stringify(processed);
|
||||
}
|
||||
|
||||
@@ -47,6 +52,9 @@ export class BackupService {
|
||||
if (type === 's3' && config.secretAccessKey) {
|
||||
config.secretAccessKey = await decryptData(config.secretAccessKey, key);
|
||||
}
|
||||
if (type === 'telegram' && config.botToken) {
|
||||
config.botToken = await decryptData(config.botToken, key);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -64,6 +72,9 @@ export class BackupService {
|
||||
if (row.type === 's3' && config.secretAccessKey) {
|
||||
config.secretAccessKey = MASK;
|
||||
}
|
||||
if (row.type === 'telegram' && config.botToken) {
|
||||
config.botToken = MASK;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
@@ -118,6 +129,9 @@ export class BackupService {
|
||||
if (type === 's3' && (config.secretAccessKey === MASK || !config.secretAccessKey)) {
|
||||
config.secretAccessKey = currentConfigBase.secretAccessKey;
|
||||
}
|
||||
if (type === 'telegram' && (config.botToken === MASK || !config.botToken)) {
|
||||
config.botToken = currentConfigBase.botToken;
|
||||
}
|
||||
}
|
||||
|
||||
const encryptedConfig = await this.processConfigForStorage(type, config, key);
|
||||
@@ -160,11 +174,14 @@ export class BackupService {
|
||||
if (type === 's3' && config.secretAccessKey === MASK) {
|
||||
config.secretAccessKey = currentConfigBase.secretAccessKey;
|
||||
}
|
||||
if (type === 'telegram' && config.botToken === MASK) {
|
||||
config.botToken = currentConfigBase.botToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = await this.getProvider(type, config);
|
||||
const provider = await this.getProvider(type, config, id);
|
||||
await provider.testConnection();
|
||||
} catch (e: any) {
|
||||
throw new AppError(`connection_failed: ${e.message}`, 400);
|
||||
@@ -230,7 +247,7 @@ export class BackupService {
|
||||
if (!providerRow) throw new AppError('provider_not_found', 404);
|
||||
|
||||
const configObj = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
|
||||
const provider = await this.getProvider(providerRow.type, configObj);
|
||||
const provider = await this.getProvider(providerRow.type, configObj, providerRow.id);
|
||||
|
||||
try {
|
||||
await provider.uploadBackup(finalFilename, finalContent);
|
||||
@@ -250,7 +267,7 @@ export class BackupService {
|
||||
|
||||
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
||||
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
|
||||
const provider = await this.getProvider(providerRow.type, config);
|
||||
const provider = await this.getProvider(providerRow.type, config, providerRow.id);
|
||||
|
||||
return await provider.listBackups();
|
||||
}
|
||||
@@ -263,7 +280,7 @@ export class BackupService {
|
||||
|
||||
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
||||
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
|
||||
const provider = await this.getProvider(providerRow.type, config);
|
||||
const provider = await this.getProvider(providerRow.type, config, providerRow.id);
|
||||
|
||||
try {
|
||||
return await provider.downloadBackup(filename);
|
||||
@@ -272,6 +289,23 @@ export class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(id: number, filename: string) {
|
||||
if (!filename) throw new AppError('filename_required', 400);
|
||||
|
||||
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
|
||||
if (!providerRow) throw new AppError('provider_not_found', 404);
|
||||
|
||||
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
|
||||
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
|
||||
const provider = await this.getProvider(providerRow.type, config, providerRow.id);
|
||||
|
||||
try {
|
||||
await provider.deleteBackup(filename);
|
||||
} catch (e: any) {
|
||||
throw new AppError(`delete_failed: ${e.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async handleScheduledBackup() {
|
||||
console.log('[Backup] Starting scheduled backup...');
|
||||
const providers = await this.db.select().from(backupProviders).all();
|
||||
@@ -315,7 +349,7 @@ export class BackupService {
|
||||
const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
|
||||
|
||||
const config = await this.processConfigForUsage(row.type as string, row.config as string, key);
|
||||
const provider = await this.getProvider(row.type as string, config);
|
||||
const provider = await this.getProvider(row.type as string, config, row.id);
|
||||
|
||||
await provider.uploadBackup(filename, fileContent);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from '@/features/backup/providers/backupProvider';
|
||||
export * from '@/features/backup/providers/webDavProvider';
|
||||
export * from '@/features/backup/providers/s3Provider';
|
||||
export * from '@/features/backup/providers/s3Provider';
|
||||
export * from '@/features/backup/providers/telegramProvider';
|
||||
@@ -9,7 +9,7 @@ export class S3Provider implements BackupProvider {
|
||||
|
||||
constructor(config: any) {
|
||||
if (!config.endpoint || !config.accessKeyId || !config.secretAccessKey || !config.bucket) {
|
||||
throw new Error('S3 configuration incomplete');
|
||||
throw new Error('s3_config_incomplete');
|
||||
}
|
||||
|
||||
this.client = new AwsClient({
|
||||
@@ -42,7 +42,7 @@ export class S3Provider implements BackupProvider {
|
||||
const url = this.getUrl('', { 'list-type': '2', 'max-keys': '1' });
|
||||
const res = await this.client.fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`S3 Error: ${res.status} ${res.statusText}`);
|
||||
throw new Error(`s3_api_error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export class S3Provider implements BackupProvider {
|
||||
async listBackups(): Promise<BackupFile[]> {
|
||||
const url = this.getUrl('', { 'list-type': '2', 'prefix': this.prefix });
|
||||
const res = await this.client.fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to list S3 objects: ${res.status}`);
|
||||
if (!res.ok) throw new Error(`s3_list_failed: ${res.status}`);
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
@@ -89,14 +89,14 @@ export class S3Provider implements BackupProvider {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`S3 Upload Failed: ${res.status}`);
|
||||
if (!res.ok) throw new Error(`s3_upload_failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async downloadBackup(filename: string): Promise<string> {
|
||||
const key = this.prefix + filename;
|
||||
const url = this.getUrl(key);
|
||||
const res = await this.client.fetch(url);
|
||||
if (!res.ok) throw new Error(`S3 Download Failed: ${res.status}`);
|
||||
if (!res.ok) throw new Error(`s3_download_failed: ${res.status}`);
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
@@ -106,6 +106,6 @@ export class S3Provider implements BackupProvider {
|
||||
const res = await this.client.fetch(url, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error(`S3 Delete Failed: ${res.status}`);
|
||||
if (!res.ok) throw new Error(`s3_delete_failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { BackupProvider, BackupFile } from '@/features/backup/providers/backupProvider';
|
||||
import { backupTelegramHistory } from '@/shared/db/schema';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
|
||||
export class TelegramProvider implements BackupProvider {
|
||||
private botToken: string;
|
||||
private chatId: string;
|
||||
private db: any;
|
||||
private providerId: number | undefined;
|
||||
|
||||
constructor(config: any, db?: any, providerId?: number) {
|
||||
if (!config.botToken || !config.chatId) {
|
||||
throw new Error('telegram_config_incomplete');
|
||||
}
|
||||
|
||||
this.botToken = config.botToken;
|
||||
this.chatId = config.chatId;
|
||||
this.db = db;
|
||||
this.providerId = providerId;
|
||||
}
|
||||
|
||||
private getApiUrl(method: string): string {
|
||||
return `https://api.telegram.org/bot${this.botToken}/${method}`;
|
||||
}
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
// Send a message and delete it immediately
|
||||
const sendUrl = this.getApiUrl('sendMessage');
|
||||
|
||||
try {
|
||||
const sendRes = await fetch(sendUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
text: '🔗 2FAuth Backup Connected / 备份源连接成功',
|
||||
disable_notification: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!sendRes.ok) {
|
||||
const errResult = (await sendRes.json()) as any;
|
||||
throw new Error(`telegram_api_error: ${errResult.description}`);
|
||||
}
|
||||
|
||||
const sendResult = (await sendRes.json()) as any;
|
||||
const messageId = sendResult.result.message_id;
|
||||
|
||||
// Try to delete immediately
|
||||
const deleteUrl = this.getApiUrl('deleteMessage');
|
||||
await fetch(deleteUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
message_id: messageId
|
||||
})
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async listBackups(): Promise<BackupFile[]> {
|
||||
if (!this.db || !this.providerId) {
|
||||
throw new Error('telegram_db_missing');
|
||||
}
|
||||
|
||||
const histories = await this.db.select()
|
||||
.from(backupTelegramHistory)
|
||||
.where(eq(backupTelegramHistory.providerId, this.providerId))
|
||||
.orderBy(desc(backupTelegramHistory.createdAt))
|
||||
.all();
|
||||
|
||||
return histories.map((h: any) => ({
|
||||
filename: h.filename,
|
||||
size: h.size,
|
||||
lastModified: new Date(h.createdAt).toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
async uploadBackup(filename: string, data: string): Promise<void> {
|
||||
if (!this.db || !this.providerId) {
|
||||
throw new Error('telegram_db_missing');
|
||||
}
|
||||
|
||||
// Use sendDocument
|
||||
const sendUrl = this.getApiUrl('sendDocument');
|
||||
const formData = new FormData();
|
||||
formData.append('chat_id', this.chatId);
|
||||
|
||||
// Convert string data to Blob
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
formData.append('document', blob, filename);
|
||||
|
||||
const res = await fetch(sendUrl, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errResult = (await res.json()) as any;
|
||||
throw new Error(`telegram_upload_failed: ${errResult.description}`);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as any;
|
||||
const document = result.result.document;
|
||||
const messageId = result.result.message_id;
|
||||
const fileId = document.file_id;
|
||||
const fileSize = document.file_size || data.length;
|
||||
|
||||
// Store in DB
|
||||
await this.db.insert(backupTelegramHistory).values({
|
||||
providerId: this.providerId,
|
||||
filename: filename,
|
||||
fileId: fileId,
|
||||
messageId: messageId,
|
||||
size: fileSize,
|
||||
createdAt: Date.now()
|
||||
}).run();
|
||||
}
|
||||
|
||||
async downloadBackup(filename: string): Promise<string> {
|
||||
if (!this.db || !this.providerId) {
|
||||
throw new Error('telegram_db_missing');
|
||||
}
|
||||
|
||||
// Find fileId
|
||||
const fileRecord = await this.db.select()
|
||||
.from(backupTelegramHistory)
|
||||
.where(and(eq(backupTelegramHistory.providerId, this.providerId), eq(backupTelegramHistory.filename, filename)))
|
||||
.get();
|
||||
|
||||
if (!fileRecord) {
|
||||
throw new Error('file_not_found_in_history');
|
||||
}
|
||||
|
||||
// Get file path
|
||||
const getFileUrl = this.getApiUrl('getFile') + `?file_id=${fileRecord.fileId}`;
|
||||
const fileInfoRes = await fetch(getFileUrl);
|
||||
if (!fileInfoRes.ok) {
|
||||
const errResult = (await fileInfoRes.json()) as any;
|
||||
if (fileInfoRes.status === 400 && errResult.description && errResult.description.includes('file is unavailable')) {
|
||||
throw new Error('FILE_UNAVAILABLE');
|
||||
}
|
||||
throw new Error(`telegram_download_failed: ${errResult.description}`);
|
||||
}
|
||||
const fileInfo = (await fileInfoRes.json()) as any;
|
||||
const filePath = fileInfo.result.file_path;
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('telegram_file_path_missing');
|
||||
}
|
||||
|
||||
// Download actual file content
|
||||
const downloadUrl = `https://api.telegram.org/file/bot${this.botToken}/${filePath}`;
|
||||
const downloadRes = await fetch(downloadUrl);
|
||||
if (!downloadRes.ok) throw new Error(`telegram_download_failed: ${downloadRes.statusText}`);
|
||||
|
||||
return await downloadRes.text();
|
||||
}
|
||||
|
||||
async deleteBackup(filename: string): Promise<void> {
|
||||
if (!this.db || !this.providerId) {
|
||||
throw new Error('telegram_db_missing');
|
||||
}
|
||||
|
||||
const fileRecord = await this.db.select()
|
||||
.from(backupTelegramHistory)
|
||||
.where(and(eq(backupTelegramHistory.providerId, this.providerId), eq(backupTelegramHistory.filename, filename)))
|
||||
.get();
|
||||
|
||||
if (!fileRecord) {
|
||||
// Already gone
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete message from telegram
|
||||
const deleteUrl = this.getApiUrl('deleteMessage');
|
||||
await fetch(deleteUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
message_id: fileRecord.messageId
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Error deleting message from Telegram:`, e);
|
||||
// We ignore Telegram errors here because it might be already deleted by the user
|
||||
}
|
||||
|
||||
// Finally, remove from DB
|
||||
await this.db.delete(backupTelegramHistory)
|
||||
.where(eq(backupTelegramHistory.id, fileRecord.id))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export class WebDavProvider implements BackupProvider {
|
||||
|
||||
constructor(config: any) {
|
||||
if (!config.url || !config.username || !config.password) {
|
||||
throw new Error('WebDAV configuration incomplete');
|
||||
throw new Error('webdav_config_incomplete');
|
||||
}
|
||||
this.config = config;
|
||||
this.client = createClient(config.url, {
|
||||
@@ -101,7 +101,7 @@ export class WebDavProvider implements BackupProvider {
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
throw new Error(`WebDAV Fallback Upload Failed: ${res.status} ${res.statusText} ${errText}`);
|
||||
throw new Error(`webdav_upload_failed: ${res.status} ${res.statusText} ${errText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,11 +129,11 @@ export class WebDavProvider implements BackupProvider {
|
||||
if (redirectUrl) {
|
||||
// 由于跨域重定向规范限制,直接抓取 location 发出裸请求(通常是带有签名的 CDN 下载直链,已无需附带 Authorization)
|
||||
const finalRes = await fetch(redirectUrl);
|
||||
if (!finalRes.ok) throw new Error(`Redirect fetch failed: ${finalRes.status}`);
|
||||
if (!finalRes.ok) throw new Error(`webdav_download_redirect_failed: ${finalRes.status}`);
|
||||
return await finalRes.text();
|
||||
}
|
||||
} else if (!res.ok) {
|
||||
throw new Error(`WebDAV Fallback Download Failed: ${res.status} ${res.statusText}`);
|
||||
throw new Error(`webdav_download_failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
return await res.text();
|
||||
@@ -153,7 +153,7 @@ export class WebDavProvider implements BackupProvider {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': this.getAuthHeader() }
|
||||
});
|
||||
if (!res.ok) throw new Error(`WebDAV Fallback Delete Failed: ${res.status} ${res.statusText}`);
|
||||
if (!res.ok) throw new Error(`webdav_delete_failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,21 @@ export const backupProviders = sqliteTable('backup_providers', {
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
// 3. Telegram 备份历史记录表
|
||||
export const backupTelegramHistory = sqliteTable('backup_telegram_history', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
providerId: integer('provider_id').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
fileId: text('file_id').notNull(),
|
||||
messageId: integer('message_id').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
// 导出类型定义
|
||||
export type VaultItem = typeof vault.$inferSelect;
|
||||
export type NewVaultItem = typeof vault.$inferInsert;
|
||||
export type BackupProvider = typeof backupProviders.$inferSelect;
|
||||
export type NewBackupProvider = typeof backupProviders.$inferInsert;
|
||||
export type NewBackupProvider = typeof backupProviders.$inferInsert;
|
||||
export type BackupTelegramHistory = typeof backupTelegramHistory.$inferSelect;
|
||||
export type NewBackupTelegramHistory = typeof backupTelegramHistory.$inferInsert;
|
||||
@@ -34,6 +34,7 @@ body,
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--el-bg-color-page);
|
||||
color: var(--el-text-color-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
@@ -84,7 +85,7 @@ a:hover {
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -106,7 +107,8 @@ main {
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
<el-select v-model="form.type" :disabled="isEditing">
|
||||
<el-option label="WebDAV" value="webdav" />
|
||||
<el-option :label="$t('backup.type_s3')" value="s3" />
|
||||
<el-option label="Telegram" value="telegram" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('backup.name_label')">
|
||||
@@ -90,7 +91,7 @@
|
||||
<el-input v-else v-model="form.config.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('backup.save_dir')">
|
||||
<el-input v-model="form.config.saveDir" placeholder="/2fauth-backups" />
|
||||
<el-input v-model="form.config.saveDir" placeholder="/2fauth-worker-backup" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
@@ -116,10 +117,28 @@
|
||||
<el-input v-else v-model="form.config.secretAccessKey" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('backup.s3_path_prefix')">
|
||||
<el-input v-model="form.config.saveDir" placeholder="backups/" />
|
||||
<el-input v-model="form.config.saveDir" placeholder="/2fauth-worker-backup" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Telegram 配置 -->
|
||||
<template v-if="form.type === 'telegram'">
|
||||
<el-form-item :label="$t('backup.telegram_bot_token')">
|
||||
<div v-if="isEditing && !isEditingTelegramToken" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
|
||||
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
|
||||
<el-button link type="primary" @click="isEditingTelegramToken = true; form.config.botToken = ''">{{ $t('backup.modify') }}</el-button>
|
||||
</div>
|
||||
<el-input v-else v-model="form.config.botToken" type="password" show-password :placeholder="$t('backup.telegram_bot_token_placeholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('backup.telegram_chat_id')">
|
||||
<el-input v-model="form.config.chatId" :placeholder="$t('backup.telegram_chat_id_placeholder')" />
|
||||
<div class="form-tip">
|
||||
<strong>{{ $t('backup.telegram_chat_id_tip_title') }}</strong><br/>
|
||||
<span>{{ $t('backup.telegram_chat_id_tip_1') }}</span><br/>
|
||||
<span>{{ $t('backup.telegram_chat_id_tip_2') }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-divider content-position="left">{{ $t('backup.auto_backup_config') }}</el-divider>
|
||||
<el-form-item :label="$t('backup.auto_backup')">
|
||||
<el-switch v-model="form.autoBackup" :active-text="$t('backup.switch_on')" :inactive-text="$t('backup.switch_off')" />
|
||||
@@ -211,7 +230,7 @@ const layoutStore = useLayoutStore()
|
||||
|
||||
const {
|
||||
providers, isLoading, showConfigDialog, isEditing, isTesting, isSaving,
|
||||
isEditingWebdavPwd, isEditingS3Secret, form,
|
||||
isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, form,
|
||||
hasExistingAutoPwd, configUseExistingAutoPwd, fetchProviders, openAddDialog,
|
||||
editProvider, testConnection, saveProvider, deleteProvider
|
||||
} = useBackupProviders()
|
||||
@@ -223,7 +242,7 @@ const {
|
||||
selectRestoreFile, handleRestore
|
||||
} = useBackupActions(emit, fetchProviders)
|
||||
|
||||
const getProviderTypeTag = (type) => type === 'webdav' ? 'primary' : (type === 's3' ? 'warning' : 'info')
|
||||
const getProviderTypeTag = (type) => type === 'webdav' ? 'primary' : (type === 's3' ? 'warning' : (type === 'telegram' ? 'success' : 'info'))
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
import { ElMessage, ElNotification, ElMessageBox } from 'element-plus'
|
||||
import { backupService } from '@/features/backup/service/backupService'
|
||||
import { dataMigrationService } from '@/features/migration/service/dataMigrationService'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
@@ -55,7 +55,7 @@ export function useBackupActions(emit, fetchProviders) {
|
||||
if (fetchProviders) await fetchProviders()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '备份失败')
|
||||
// Already handled by request.js
|
||||
} finally { isBackingUp.value = false }
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useBackupActions(emit, fetchProviders) {
|
||||
const res = await backupService.getBackupFiles(provider.id)
|
||||
if (res.success) backupFiles.value = res.files
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || t('backup.fetch_backup_fail'))
|
||||
// Error handled by request.js
|
||||
} finally { isLoadingFiles.value = false }
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function useBackupActions(emit, fetchProviders) {
|
||||
const handleRestore = async () => {
|
||||
isRestoring.value = true
|
||||
try {
|
||||
const downloadRes = await backupService.downloadBackupFile(currentActionProvider.value.id, selectedFile.value.filename)
|
||||
const downloadRes = await backupService.downloadBackupFile(currentActionProvider.value.id, selectedFile.value.filename, true)
|
||||
|
||||
let contentToDecrypt = downloadRes.content
|
||||
try {
|
||||
@@ -119,7 +119,34 @@ export function useBackupActions(emit, fetchProviders) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || t('backup.restore_fail'))
|
||||
// Check if the backend told us the file was unavailable/deleted on Telegram
|
||||
if (e.message?.includes('FILE_UNAVAILABLE')) {
|
||||
// Close the password prompt, but keep the restore list dialog open
|
||||
showRestoreConfirmDialog.value = false
|
||||
|
||||
ElMessageBox.confirm(
|
||||
t('backup.file_unavailable'),
|
||||
t('backup.record_invalid'),
|
||||
{
|
||||
confirmButtonText: t('backup.confirm_clean'),
|
||||
cancelButtonText: t('backup.cancel_clean'),
|
||||
type: 'warning'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
await backupService.deleteBackupFile(currentActionProvider.value.id, selectedFile.value.filename)
|
||||
if (selectedFile.value) {
|
||||
backupFiles.value = backupFiles.value.filter(f => f.filename !== selectedFile.value.filename)
|
||||
}
|
||||
ElMessage.success(t('backup.clean_record_success'))
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message || t('backup.clean_record_fail'))
|
||||
}
|
||||
}).catch(() => { })
|
||||
} else if (!e.message?.includes('connection_failed')) {
|
||||
// Keep local toast for decryption errors that don't originate from fetch
|
||||
ElMessage.error(e.message || t('backup.restore_fail'))
|
||||
}
|
||||
} finally { isRestoring.value = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,12 @@ export function useBackupProviders() {
|
||||
const isSaving = ref(false)
|
||||
const isEditingWebdavPwd = ref(false)
|
||||
const isEditingS3Secret = ref(false)
|
||||
const isEditingTelegramToken = ref(false)
|
||||
|
||||
const initialFormState = () => ({
|
||||
type: 'webdav',
|
||||
name: '',
|
||||
config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' },
|
||||
config: { url: '', username: '', password: '', saveDir: '/2fauth-worker-backup', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '', botToken: '', chatId: '' },
|
||||
autoBackup: false,
|
||||
autoBackupPassword: '',
|
||||
autoBackupRetain: 30
|
||||
@@ -55,6 +56,7 @@ export function useBackupProviders() {
|
||||
isEditing.value = false
|
||||
isEditingWebdavPwd.value = false
|
||||
isEditingS3Secret.value = false
|
||||
isEditingTelegramToken.value = false
|
||||
form.value = initialFormState()
|
||||
hasExistingAutoPwd.value = false
|
||||
configUseExistingAutoPwd.value = false
|
||||
@@ -65,6 +67,7 @@ export function useBackupProviders() {
|
||||
isEditing.value = true
|
||||
isEditingWebdavPwd.value = false
|
||||
isEditingS3Secret.value = false
|
||||
isEditingTelegramToken.value = false
|
||||
currentProviderId.value = provider.id
|
||||
form.value = JSON.parse(JSON.stringify({
|
||||
type: provider.type,
|
||||
@@ -91,6 +94,9 @@ export function useBackupProviders() {
|
||||
if (!c.bucket) return t('backup.require_bucket')
|
||||
if (!c.accessKeyId) return t('backup.require_access_key')
|
||||
if (!c.secretAccessKey) return t('backup.require_secret_key')
|
||||
} else if (form.value.type === 'telegram') {
|
||||
if (!c.botToken) return t('backup.require_telegram_token')
|
||||
if (!c.chatId) return t('backup.require_telegram_chat_id')
|
||||
}
|
||||
|
||||
if (form.value.autoBackup) {
|
||||
@@ -117,7 +123,7 @@ export function useBackupProviders() {
|
||||
)
|
||||
if (res.success) ElMessage.success('连接成功')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '连接失败')
|
||||
// Already handled by request.js global error handler
|
||||
} finally { isTesting.value = false }
|
||||
}
|
||||
|
||||
@@ -140,7 +146,7 @@ export function useBackupProviders() {
|
||||
await fetchProviders()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
// Already handled by request.js
|
||||
} finally { isSaving.value = false }
|
||||
}
|
||||
|
||||
@@ -150,9 +156,7 @@ export function useBackupProviders() {
|
||||
await backupService.deleteProvider(provider.id)
|
||||
await fetchProviders()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
// Error handled by request.js (unless it's 'cancel' from ElMessageBox)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +171,7 @@ export function useBackupProviders() {
|
||||
isSaving,
|
||||
isEditingWebdavPwd,
|
||||
isEditingS3Secret,
|
||||
isEditingTelegramToken,
|
||||
form,
|
||||
hasExistingAutoPwd,
|
||||
configUseExistingAutoPwd,
|
||||
|
||||
@@ -143,17 +143,37 @@ export const backupService = {
|
||||
* 下载加密的备份文件
|
||||
* @param {string} id
|
||||
* @param {string} filename
|
||||
* @param {boolean} silent
|
||||
* @returns {Promise<{success: boolean, content: string|Object}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async downloadBackupFile(id, filename) {
|
||||
async downloadBackupFile(id, filename, silent = false) {
|
||||
try {
|
||||
return await request(`/api/backups/providers/${id}/download`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename })
|
||||
body: JSON.stringify({ filename }),
|
||||
silent
|
||||
})
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to download backup file', 'FILE_DOWNLOAD_FAILED', e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除云端备份文件(如遇到幽灵记录)
|
||||
* @param {string} id
|
||||
* @param {string} filename
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
* @throws {backupError}
|
||||
*/
|
||||
async deleteBackupFile(id, filename) {
|
||||
try {
|
||||
return await request(`/api/backups/providers/${id}/files/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename })
|
||||
})
|
||||
} catch (e) {
|
||||
throw new backupError('Failed to delete backup file', 'FILE_DELETE_FAILED', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="vault-list-wrapper" style="min-height: 400px;">
|
||||
<div class="vault-content">
|
||||
<!-- 移动端偏移 45px (Header2), PC端偏移 61px (Header1) -->
|
||||
<el-affix :offset="layoutStore.isMobile ? 45 : 61" @change="(val) => isToolbarFixed = val">
|
||||
<!-- 移动端偏移 0px (Header2), PC端偏移 61px (Header1) -->
|
||||
<el-affix :offset="layoutStore.isMobile ? 0 : 61" @change="(val) => isToolbarFixed = val">
|
||||
<div class="toolbar" :class="{ 'is-affixed': isToolbarFixed }" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex: 1;">
|
||||
<el-input
|
||||
@@ -272,10 +272,8 @@ onMounted(handleUnlocked)
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
background-color: var(--glass-card-bg, rgba(255, 255, 255, 0.95));
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
padding: 10px 20px !important;
|
||||
background-color: var(--el-bg-color-page);
|
||||
padding: 14px 28px 14px 20px !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -283,18 +281,10 @@ onMounted(handleUnlocked)
|
||||
/* PC 端吸顶效果:保持原有宽度,无背景色和下边框 */
|
||||
@media (min-width: 768px) {
|
||||
.toolbar.is-affixed {
|
||||
background-color: var(--el-bg-color-page) !important;
|
||||
background-color: var(--el-bg-color) !important;
|
||||
border-bottom: none !important;
|
||||
padding: 10px 0px 20px 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 适配暗黑模式 (仅移动端需要背景) */
|
||||
@media (max-width: 767px) {
|
||||
html.dark .toolbar.is-affixed {
|
||||
background-color: rgba(30, 30, 30, 0.85);
|
||||
border-bottom: 1px solid var(--el-border-color-dark);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,12 +28,13 @@ export function useVaultList(afterLoadRef = null) {
|
||||
const pageSize = ref(12)
|
||||
|
||||
// --- Vue Query 分页逻辑 ---
|
||||
const fetchVaultPage = async ({ pageParam = 1 }) => {
|
||||
const fetchVaultPage = async ({ pageParam = 1, queryKey }) => {
|
||||
if (!vaultStore.isUnlocked) return { vault: [], pagination: { totalPages: 0 } }
|
||||
return await vaultService.getVault({
|
||||
page: pageParam,
|
||||
limit: pageSize.value,
|
||||
search: searchQuery.value
|
||||
// queryKey[1] is the search string for THIS query's cache key, provided by Vue Query
|
||||
search: (queryKey && queryKey.length > 1) ? queryKey[1] : searchQuery.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,11 +71,11 @@ export function useVaultList(afterLoadRef = null) {
|
||||
const existing = vault.value.find(a => a.id === newAcc.id)
|
||||
if (existing) {
|
||||
// 检测关键参数变化:digits, secret, algorithm
|
||||
const hasParameterChange =
|
||||
const hasParameterChange =
|
||||
existing.digits !== newAcc.digits ||
|
||||
existing.secret !== newAcc.secret ||
|
||||
existing.algorithm !== newAcc.algorithm
|
||||
|
||||
|
||||
// 若参数变化,强制清空验证码以触发重新计算
|
||||
if (hasParameterChange) {
|
||||
return {
|
||||
@@ -84,7 +85,7 @@ export function useVaultList(afterLoadRef = null) {
|
||||
forceCompute: true // 标记为需要优先计算
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { ...existing, ...newAcc } // 保留 currentCode / remaining 等动态字段
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -138,6 +138,21 @@
|
||||
"s3_bucket": "Bucket Name",
|
||||
"s3_region": "Region",
|
||||
"s3_path_prefix": "Path Prefix (Optional)",
|
||||
"telegram_bot_token": "Bot Token",
|
||||
"telegram_bot_token_placeholder": "123456789:ABCdefGHIjklmNOPQrsTUVwxyZ",
|
||||
"telegram_chat_id": "Chat ID",
|
||||
"telegram_chat_id_placeholder": "e.g., 123456789 or -100123456789",
|
||||
"telegram_chat_id_tip_title": "How to get Chat ID",
|
||||
"telegram_chat_id_tip_1": "1. Private chat back up: Search for userinfobot and send /start to get your ID.",
|
||||
"telegram_chat_id_tip_2": "2. Group/Channel back up: Add your Bot to the destination group and grant message sending & deleting permissions, then send any message to get the group ID (usually starts with -100).",
|
||||
"require_telegram_token": "Please enter Bot Token",
|
||||
"require_telegram_chat_id": "Please enter Chat ID",
|
||||
"file_unavailable": "This backup file is unavailable or deleted on Telegram. Do you want to clean up this invalid record?",
|
||||
"record_invalid": "Record Invalid",
|
||||
"confirm_clean": "Clean Up",
|
||||
"cancel_clean": "Not Now",
|
||||
"clean_record_success": "Invalid record cleaned up",
|
||||
"clean_record_fail": "Failed to clean record",
|
||||
"auto_backup_config": "Auto Backup Configuration",
|
||||
"switch_on": "ON",
|
||||
"switch_off": "OFF",
|
||||
@@ -435,6 +450,7 @@
|
||||
"connection_failed": "Connection failed",
|
||||
"backup_failed": "Backup failed",
|
||||
"download_failed": "Download failed",
|
||||
"delete_failed": "Delete failed",
|
||||
"oauth_config_incomplete": "OAuth configuration incomplete",
|
||||
"oauth_api_error": "OAuth API error",
|
||||
"oauth_token_exchange_failed": "OAuth token exchange failed",
|
||||
@@ -444,6 +460,24 @@
|
||||
"telegram_missing_hash": "Missing hash signature",
|
||||
"telegram_signature_failed": "Telegram signature verification failed",
|
||||
"telegram_login_expired": "Telegram login data has expired",
|
||||
"telegram_config_incomplete": "Telegram config incomplete (missing Token or Chat ID)",
|
||||
"telegram_db_missing": "Database connection or Provider ID is missing",
|
||||
"telegram_api_error": "Telegram API Error",
|
||||
"telegram_upload_failed": "Telegram upload failed",
|
||||
"telegram_download_failed": "Telegram download failed",
|
||||
"file_not_found_in_history": "File not found in history",
|
||||
"telegram_file_path_missing": "File path not returned from Telegram",
|
||||
"s3_config_incomplete": "S3 configuration incomplete",
|
||||
"s3_api_error": "S3 API Error",
|
||||
"s3_list_failed": "Failed to list S3 objects",
|
||||
"s3_upload_failed": "S3 upload failed",
|
||||
"s3_download_failed": "S3 download failed",
|
||||
"s3_delete_failed": "S3 delete failed",
|
||||
"webdav_config_incomplete": "WebDAV configuration incomplete",
|
||||
"webdav_upload_failed": "WebDAV fallback upload failed",
|
||||
"webdav_download_redirect_failed": "WebDAV redirect fetch failed",
|
||||
"webdav_download_failed": "WebDAV fallback download failed",
|
||||
"webdav_delete_failed": "WebDAV fallback delete failed",
|
||||
"unsupported_provider": "Unsupported provider",
|
||||
"health_check_failed": "Security vulnerability detected! System access is locked."
|
||||
},
|
||||
|
||||
@@ -138,6 +138,21 @@
|
||||
"s3_bucket": "Bucket (存储桶名称)",
|
||||
"s3_region": "Region (区域)",
|
||||
"s3_path_prefix": "存储路径前缀 (可选)",
|
||||
"telegram_bot_token": "Bot Token",
|
||||
"telegram_bot_token_placeholder": "123456789:ABCdefGHIjklmNOPQrsTUVwxyZ",
|
||||
"telegram_chat_id": "Chat ID",
|
||||
"telegram_chat_id_placeholder": "例如:123456789 或 -100123456789",
|
||||
"telegram_chat_id_tip_title": "如何获取 Chat ID",
|
||||
"telegram_chat_id_tip_1": "1. 私聊备份:搜索 userinfobot 发送 /start 获取您的专属 ID",
|
||||
"telegram_chat_id_tip_2": "2. 群组/频道备份:将 Bot 拉入目标群组并赋予发言与删除消息权限,发送任意消息后获取群组 ID(通常以 -100 开头)",
|
||||
"require_telegram_token": "请填写 Bot Token",
|
||||
"require_telegram_chat_id": "请填写 Chat ID",
|
||||
"file_unavailable": "该备份文件在 Telegram 中已失效或被删除,是否清理此无效记录?",
|
||||
"record_invalid": "记录失效",
|
||||
"confirm_clean": "确定清理",
|
||||
"cancel_clean": "暂不清理",
|
||||
"clean_record_success": "已清理失效记录",
|
||||
"clean_record_fail": "清理记录失败",
|
||||
"auto_backup_config": "自动备份配置",
|
||||
"switch_on": "开启",
|
||||
"switch_off": "关闭",
|
||||
@@ -435,6 +450,7 @@
|
||||
"connection_failed": "连接失败",
|
||||
"backup_failed": "备份失败",
|
||||
"download_failed": "下载失败",
|
||||
"delete_failed": "删除失败",
|
||||
"oauth_config_incomplete": "OAuth 配置不完整",
|
||||
"oauth_api_error": "OAuth API 错误",
|
||||
"oauth_token_exchange_failed": "OAuth token 交换失败",
|
||||
@@ -444,6 +460,24 @@
|
||||
"telegram_missing_hash": "缺少哈希签名",
|
||||
"telegram_signature_failed": "Telegram 签名验证失败",
|
||||
"telegram_login_expired": "Telegram 登录数据已过期",
|
||||
"telegram_config_incomplete": "Telegram 配置不完整 (缺少 Token 或 Chat ID)",
|
||||
"telegram_db_missing": "缺少数据库连接或 Provider ID",
|
||||
"telegram_api_error": "Telegram API 错误",
|
||||
"telegram_upload_failed": "Telegram 上传失败",
|
||||
"telegram_download_failed": "Telegram 下载失败",
|
||||
"file_not_found_in_history": "在历史记录中找不到此文件",
|
||||
"telegram_file_path_missing": "Telegram 未返回文件路径",
|
||||
"s3_config_incomplete": "S3 配置不完整",
|
||||
"s3_api_error": "S3 API 错误",
|
||||
"s3_list_failed": "获取 S3 对象列表失败",
|
||||
"s3_upload_failed": "S3 上传失败",
|
||||
"s3_download_failed": "S3 下载失败",
|
||||
"s3_delete_failed": "S3 删除失败",
|
||||
"webdav_config_incomplete": "WebDAV 配置不完整",
|
||||
"webdav_upload_failed": "WebDAV 备选方案上传失败",
|
||||
"webdav_download_redirect_failed": "WebDAV 重定向请求失败",
|
||||
"webdav_download_failed": "WebDAV 备选方案下载失败",
|
||||
"webdav_delete_failed": "WebDAV 备选方案删除失败",
|
||||
"unsupported_provider": "不支持的提供商",
|
||||
"health_check_failed": "环境配置漏洞警告,系统已被锁定!"
|
||||
},
|
||||
|
||||
+11
-6
@@ -24,21 +24,22 @@ const themeStore = useThemeStore()
|
||||
themeStore.initTheme()
|
||||
|
||||
// PWA Service Worker 注册
|
||||
registerSW({
|
||||
onOfflineReady() {
|
||||
const updateSW = registerSW({
|
||||
onofflineReady() { },
|
||||
onNeedRefresh() {
|
||||
import('element-plus').then(({ ElNotification }) => {
|
||||
ElNotification({
|
||||
title: i18n.global.t('common.update_available'),
|
||||
title: i18n.global.t('common.update_available') || 'Update Available',
|
||||
message: `
|
||||
<div style="line-height: 1.5; font-size: 14px; margin-bottom: 5px;">
|
||||
🎉 ${i18n.global.t('common.pwa_update_ready')}
|
||||
🎉 ${i18n.global.t('common.pwa_update_ready') || 'A new version is ready!'}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
style="background: var(--el-color-primary); color: white; border: none; padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;"
|
||||
onclick="window.location.reload()"
|
||||
id="pwa-refresh-btn"
|
||||
>
|
||||
${i18n.global.t('common.refresh_now')}
|
||||
${i18n.global.t('common.refresh_now') || 'Refresh Now'}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
@@ -46,6 +47,10 @@ registerSW({
|
||||
type: 'success',
|
||||
duration: 0
|
||||
})
|
||||
setTimeout(() => {
|
||||
const btn = document.getElementById('pwa-refresh-btn')
|
||||
if (btn) btn.onclick = () => updateSW(true)
|
||||
}, 100)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,6 +85,12 @@ export async function request(url, options = {}) {
|
||||
: fallbackMessage
|
||||
|
||||
if (errorDetails) {
|
||||
let detailParts = errorDetails.split(':')
|
||||
let detailKey = detailParts[0].trim()
|
||||
if (i18n.global.te(`api_errors.${detailKey}`)) {
|
||||
detailParts[0] = i18n.global.t(`api_errors.${detailKey}`)
|
||||
errorDetails = detailParts.join(': ')
|
||||
}
|
||||
translatedMessage = `${translatedMessage}: ${errorDetails}`
|
||||
}
|
||||
|
||||
@@ -134,6 +140,12 @@ export async function request(url, options = {}) {
|
||||
: fallbackError
|
||||
|
||||
if (errorDetails) {
|
||||
let detailParts = errorDetails.split(':')
|
||||
let detailKey = detailParts[0].trim()
|
||||
if (i18n.global.te(`api_errors.${detailKey}`)) {
|
||||
detailParts[0] = i18n.global.t(`api_errors.${detailKey}`)
|
||||
errorDetails = detailParts.join(': ')
|
||||
}
|
||||
translatedError = `${translatedError}: ${errorDetails}`
|
||||
}
|
||||
ElMessage.error(translatedError)
|
||||
@@ -152,6 +164,12 @@ export async function request(url, options = {}) {
|
||||
// 屏蔽被 silent 处理过的 Auth Error
|
||||
if (error.message !== 'Unauthorized/Forbidden' && !options.silent) {
|
||||
console.error('API Request Error:', error)
|
||||
|
||||
// Only show toast if it wasn't already shown in the !response.ok block above
|
||||
// The throwing logic above throws an Error with a specific format, we only toast here if it's a fetch/network level error
|
||||
if (error.name === 'TypeError' || error.message.includes('fetch')) {
|
||||
ElMessage.error(i18n.global.te('api_errors.network_error') ? i18n.global.t('api_errors.network_error') : '网络请求失败')
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user