Initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
electron-data/
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode/
|
||||
22
README.md
Normal file
22
README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Backup Manager
|
||||
|
||||
便携式备份管理工具 (Portable Backup Management Tool)
|
||||
|
||||
## 项目介绍
|
||||
这是一个基于 Electron 的便携式代码/数据备份管理工具。
|
||||
|
||||
## 运行与开发
|
||||
安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
启动开发环境:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
构建便携版:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
23
eslint.config.mjs
Normal file
23
eslint.config.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.electron
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": "warn",
|
||||
"no-console": "off",
|
||||
"semi": ["error", "always"],
|
||||
"quotes": ["error", "double"]
|
||||
}
|
||||
}
|
||||
];
|
||||
189
index.html
Normal file
189
index.html
Normal file
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>备份管理器</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 标题栏 -->
|
||||
<div class="titlebar">
|
||||
<div class="titlebar-drag">
|
||||
<i class="fa-solid fa-box-archive titlebar-icon"></i>
|
||||
<span class="titlebar-title">备份管理器</span>
|
||||
</div>
|
||||
<div class="titlebar-controls">
|
||||
<button class="titlebar-btn minimize" onclick="window.electronAPI.minimize()"><i
|
||||
class="fa-solid fa-minus"></i></button>
|
||||
<button class="titlebar-btn maximize" onclick="window.electronAPI.maximize()"><i
|
||||
class="fa-regular fa-square"></i></button>
|
||||
<button class="titlebar-btn close" onclick="window.electronAPI.close()"><i
|
||||
class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主容器 -->
|
||||
<div class="container">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-label">数据库</span>
|
||||
<button class="btn-icon" id="btnNewDb" onclick="showNewDbModal()" title="新建数据库">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="database-list" id="databaseList">
|
||||
<!-- 数据库列表动态生成 -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content">
|
||||
<!-- 未选择数据库提示 -->
|
||||
<div class="no-database" id="noDatabase">
|
||||
<i class="fa-solid fa-folder-open no-database-icon"></i>
|
||||
<h2>选择数据库开始</h2>
|
||||
<p>从左侧选择或新建数据库</p>
|
||||
</div>
|
||||
|
||||
<!-- 数据库内容区 -->
|
||||
<div class="database-content" id="databaseContent" style="display: none;">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<i class="fa-solid fa-search"></i>
|
||||
<input type="text" id="searchInput" placeholder="搜索备份..." oninput="filterBackups()">
|
||||
</div>
|
||||
<div class="toolbar-info">
|
||||
<span id="backupCount">0 个备份</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽区域 -->
|
||||
<div class="drop-zone" id="dropZone">
|
||||
<i class="fa-solid fa-cloud-arrow-up drop-icon"></i>
|
||||
<span>拖拽文件或文件夹到此处导入</span>
|
||||
</div>
|
||||
|
||||
<!-- 备份列表 -->
|
||||
<div class="backup-list" id="backupList">
|
||||
<!-- 备份项动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑数据库模态框 -->
|
||||
<div class="modal" id="dbModal">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="dbModalTitle">新建数据库</h3>
|
||||
<button class="modal-close" onclick="closeModal('dbModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="dbName">数据库名称</label>
|
||||
<input type="text" id="dbName" placeholder="输入名称(如:LittleW)" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('dbModal')">取消</button>
|
||||
<button class="btn btn-primary" id="btnConfirmDb">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签编辑模态框 -->
|
||||
<div class="modal" id="tagModal">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>编辑标签</h3>
|
||||
<button class="modal-close" onclick="closeModal('tagModal')"><i class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="tagInput">标签备注</label>
|
||||
<input type="text" id="tagInput" placeholder="输入标签(如:稳定版 v1.0)" maxlength="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('tagModal')">取消</button>
|
||||
<button class="btn btn-danger" id="btnClearTag">清除</button>
|
||||
<button class="btn btn-primary" id="btnConfirmTag">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认对话框 -->
|
||||
<div class="modal" id="confirmModal">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3 id="confirmTitle">确认</h3>
|
||||
<button class="modal-close" onclick="closeModal('confirmModal')"><i
|
||||
class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirmMessage">确定要执行此操作吗?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('confirmModal')">取消</button>
|
||||
<button class="btn btn-primary" id="btnConfirmAction">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件名不匹配模态框 -->
|
||||
<div class="modal" id="nameMismatchModal">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i> 文件名不匹配</h3>
|
||||
<button class="modal-close" onclick="closeModal('nameMismatchModal')"><i
|
||||
class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mismatch-info">
|
||||
<div class="mismatch-row">
|
||||
<span class="mismatch-label">源文件</span>
|
||||
<span class="mismatch-value" id="sourceFileName">-</span>
|
||||
</div>
|
||||
<div class="mismatch-row">
|
||||
<span class="mismatch-label">数据库</span>
|
||||
<span class="mismatch-value" id="targetDbName">-</span>
|
||||
</div>
|
||||
<div class="mismatch-row">
|
||||
<span class="mismatch-label">重命名为</span>
|
||||
<span class="mismatch-value highlight" id="newFileName">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('nameMismatchModal')">取消</button>
|
||||
<button class="btn btn-primary" id="btnConfirmImport">确认导入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast 提示 -->
|
||||
<div class="toast" id="toast">
|
||||
<i class="toast-icon" id="toastIcon"></i>
|
||||
<span class="toast-message" id="toastMessage"></span>
|
||||
</div>
|
||||
|
||||
<!-- 加载遮罩 -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text" id="loadingText">处理中...</p>
|
||||
</div>
|
||||
|
||||
<script src="scripts/renderer.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
85
main.js
Normal file
85
main.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// 便携版:设置用户数据目录为程序目录下
|
||||
const portableDataPath = path.join(__dirname, "data");
|
||||
|
||||
let mainWindow;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 820,
|
||||
height: 580,
|
||||
minWidth: 720,
|
||||
minHeight: 500,
|
||||
title: "备份管理器",
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
preload: path.join(__dirname, "preload.js")
|
||||
},
|
||||
frame: false,
|
||||
backgroundColor: "#faf7f2"
|
||||
});
|
||||
|
||||
mainWindow.loadFile("index.html");
|
||||
|
||||
// 开发时打开DevTools
|
||||
// mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
// 确保数据目录存在
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(portableDataPath)) {
|
||||
fs.mkdirSync(portableDataPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
ensureDataDir();
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// ============ IPC 处理 ============
|
||||
|
||||
// 窗口控制
|
||||
ipcMain.handle("window-minimize", () => mainWindow.minimize());
|
||||
ipcMain.handle("window-maximize", () => {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("window-close", () => mainWindow.close());
|
||||
|
||||
// 获取数据目录路径
|
||||
ipcMain.handle("get-data-path", () => portableDataPath);
|
||||
|
||||
// 选择导出目录
|
||||
ipcMain.handle("select-export-dir", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openDirectory"],
|
||||
title: "选择导出目录"
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
});
|
||||
|
||||
// 在文件管理器中显示文件
|
||||
ipcMain.handle("show-in-folder", (event, filePath) => {
|
||||
shell.showItemInFolder(filePath);
|
||||
});
|
||||
5090
package-lock.json
generated
Normal file
5090
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "backup-manager",
|
||||
"version": "1.0.0",
|
||||
"description": "便携式备份管理工具",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build": "electron-builder",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"eslint": "^9.39.2",
|
||||
"globals": "^17.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^6.0.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.backup-manager.app",
|
||||
"productName": "备份管理器",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"win": {
|
||||
"target": "portable"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "备份管理器.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
346
preload.js
Normal file
346
preload.js
Normal file
@@ -0,0 +1,346 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// ============ 数据库管理(纯JSON实现,无需sql.js)============
|
||||
class SimpleDB {
|
||||
constructor(dataPath) {
|
||||
this.dataPath = dataPath;
|
||||
this.currentDbName = null;
|
||||
this.currentData = null;
|
||||
}
|
||||
|
||||
getDbPath(name) {
|
||||
return path.join(this.dataPath, `${name}.json`);
|
||||
}
|
||||
|
||||
getStoragePath(name) {
|
||||
return path.join(this.dataPath, name);
|
||||
}
|
||||
|
||||
listDatabases() {
|
||||
if (!fs.existsSync(this.dataPath)) return [];
|
||||
const files = fs.readdirSync(this.dataPath);
|
||||
const databases = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
const name = file.replace(".json", "");
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(this.getDbPath(name), "utf8"));
|
||||
const totalSize = data.backups.reduce((sum, b) => sum + (b.file_size || 0), 0);
|
||||
databases.push({ name, count: data.backups.length, totalSize });
|
||||
} catch {
|
||||
databases.push({ name, count: 0, totalSize: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return databases;
|
||||
}
|
||||
|
||||
createDatabase(name) {
|
||||
const dbPath = this.getDbPath(name);
|
||||
const storagePath = this.getStoragePath(name);
|
||||
|
||||
if (fs.existsSync(dbPath)) {
|
||||
throw new Error(`数据库 "${name}" 已存在`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(storagePath, { recursive: true });
|
||||
fs.writeFileSync(dbPath, JSON.stringify({ backups: [] }, null, 2), "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteDatabase(name) {
|
||||
const dbPath = this.getDbPath(name);
|
||||
const storagePath = this.getStoragePath(name);
|
||||
|
||||
if (this.currentDbName === name) {
|
||||
this.currentDbName = null;
|
||||
this.currentData = null;
|
||||
}
|
||||
|
||||
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
|
||||
if (fs.existsSync(storagePath)) {
|
||||
fs.rmSync(storagePath, { recursive: true, force: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
renameDatabase(oldName, newName) {
|
||||
const oldDbPath = this.getDbPath(oldName);
|
||||
const newDbPath = this.getDbPath(newName);
|
||||
const oldStoragePath = this.getStoragePath(oldName);
|
||||
const newStoragePath = this.getStoragePath(newName);
|
||||
|
||||
if (fs.existsSync(newDbPath)) {
|
||||
throw new Error(`数据库 "${newName}" 已存在`);
|
||||
}
|
||||
|
||||
// 读取并更新数据
|
||||
const data = JSON.parse(fs.readFileSync(oldDbPath, "utf8"));
|
||||
data.backups = data.backups.map(b => ({
|
||||
...b,
|
||||
filename: b.filename.replace(oldName, newName)
|
||||
}));
|
||||
|
||||
// 保存新数据库
|
||||
fs.writeFileSync(newDbPath, JSON.stringify(data, null, 2), "utf8");
|
||||
fs.unlinkSync(oldDbPath);
|
||||
|
||||
// 重命名存储目录和文件
|
||||
if (fs.existsSync(oldStoragePath)) {
|
||||
fs.renameSync(oldStoragePath, newStoragePath);
|
||||
const files = fs.readdirSync(newStoragePath);
|
||||
for (const file of files) {
|
||||
if (file.startsWith(oldName)) {
|
||||
const newFilename = file.replace(oldName, newName);
|
||||
fs.renameSync(
|
||||
path.join(newStoragePath, file),
|
||||
path.join(newStoragePath, newFilename)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentDbName === oldName) {
|
||||
this.currentDbName = newName;
|
||||
this.currentData = data;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
openDatabase(name) {
|
||||
const dbPath = this.getDbPath(name);
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error(`数据库 "${name}" 不存在`);
|
||||
}
|
||||
this.currentDbName = name;
|
||||
this.currentData = JSON.parse(fs.readFileSync(dbPath, "utf8"));
|
||||
return true;
|
||||
}
|
||||
|
||||
saveDatabase() {
|
||||
if (this.currentDbName && this.currentData) {
|
||||
fs.writeFileSync(
|
||||
this.getDbPath(this.currentDbName),
|
||||
JSON.stringify(this.currentData, null, 2),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getBackups() {
|
||||
if (!this.currentData) return [];
|
||||
return [...this.currentData.backups].sort((a, b) =>
|
||||
b.date_modified.localeCompare(a.date_modified)
|
||||
);
|
||||
}
|
||||
|
||||
findByDateModified(dateModified) {
|
||||
if (!this.currentData) return null;
|
||||
return this.currentData.backups.find(b => b.date_modified === dateModified) || null;
|
||||
}
|
||||
|
||||
upsertBackup(info) {
|
||||
if (!this.currentData) throw new Error("没有打开的数据库");
|
||||
|
||||
const idx = this.currentData.backups.findIndex(b => b.date_modified === info.date_modified);
|
||||
|
||||
if (idx >= 0) {
|
||||
this.currentData.backups[idx] = { ...this.currentData.backups[idx], ...info };
|
||||
this.saveDatabase();
|
||||
return { action: "updated", id: idx };
|
||||
} else {
|
||||
const id = Date.now();
|
||||
this.currentData.backups.push({ id, ...info });
|
||||
this.saveDatabase();
|
||||
return { action: "inserted", id };
|
||||
}
|
||||
}
|
||||
|
||||
updateTag(id, tag) {
|
||||
if (!this.currentData) throw new Error("没有打开的数据库");
|
||||
const backup = this.currentData.backups.find(b => b.id === id);
|
||||
if (backup) {
|
||||
backup.tag = tag;
|
||||
this.saveDatabase();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteBackup(id) {
|
||||
if (!this.currentData) throw new Error("没有打开的数据库");
|
||||
const idx = this.currentData.backups.findIndex(b => b.id === id);
|
||||
if (idx >= 0) {
|
||||
const backup = this.currentData.backups[idx];
|
||||
const filePath = path.join(this.getStoragePath(this.currentDbName), backup.filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
this.currentData.backups.splice(idx, 1);
|
||||
this.saveDatabase();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getCurrentDbName() { return this.currentDbName; }
|
||||
getCurrentStoragePath() {
|
||||
return this.currentDbName ? this.getStoragePath(this.currentDbName) : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 压缩工具 ============
|
||||
const archiver = require("archiver");
|
||||
|
||||
function compressFolder(folderPath, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(outputPath);
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
|
||||
output.on("close", () => resolve({ size: archive.pointer(), path: outputPath }));
|
||||
archive.on("error", reject);
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(folderPath, false);
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
function copyArchive(source, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.copyFile(source, dest, err => err ? reject(err) : resolve(dest));
|
||||
});
|
||||
}
|
||||
|
||||
function isSupportedArchive(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return [".zip", ".rar", ".7z", ".tar", ".gz"].includes(ext);
|
||||
}
|
||||
|
||||
function getArchiveType(filePath) {
|
||||
return path.extname(filePath).toLowerCase().replace(".", "");
|
||||
}
|
||||
|
||||
// ============ 文件工具 ============
|
||||
function getLatestModifiedTime(folderPath) {
|
||||
let latest = new Date(0);
|
||||
|
||||
function scan(dir) {
|
||||
const items = fs.readdirSync(dir);
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(dir, item);
|
||||
const stats = fs.statSync(itemPath);
|
||||
if (stats.isDirectory()) {
|
||||
scan(itemPath);
|
||||
} else if (stats.mtime > latest) {
|
||||
latest = stats.mtime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan(folderPath);
|
||||
return latest;
|
||||
}
|
||||
|
||||
function formatDateModified(date) {
|
||||
const d = new Date(date);
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formatDateDisplay(str) {
|
||||
if (!str || str.length !== 12) return str;
|
||||
return `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)} ${str.slice(8, 10)}:${str.slice(10, 12)}`;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
||||
}
|
||||
|
||||
function generateBackupFilename(dbName, dateModified, archiveType) {
|
||||
return `${dbName}-${dateModified}.${archiveType}`;
|
||||
}
|
||||
|
||||
function getBaseName(filename) {
|
||||
return path.basename(filename, path.extname(filename));
|
||||
}
|
||||
|
||||
function isNameMatching(filename, dbName) {
|
||||
return getBaseName(filename).toLowerCase().startsWith(dbName.toLowerCase());
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
// ============ 初始化数据库 ============
|
||||
let db = null;
|
||||
|
||||
// ============ 暴露API ============
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// 窗口控制
|
||||
minimize: () => ipcRenderer.invoke("window-minimize"),
|
||||
maximize: () => ipcRenderer.invoke("window-maximize"),
|
||||
close: () => ipcRenderer.invoke("window-close"),
|
||||
|
||||
// 路径
|
||||
getDataPath: () => ipcRenderer.invoke("get-data-path"),
|
||||
selectExportDir: () => ipcRenderer.invoke("select-export-dir"),
|
||||
showInFolder: (p) => ipcRenderer.invoke("show-in-folder", p),
|
||||
|
||||
// 初始化
|
||||
initDatabase: async () => {
|
||||
const dataPath = await ipcRenderer.invoke("get-data-path");
|
||||
db = new SimpleDB(dataPath);
|
||||
return dataPath;
|
||||
},
|
||||
|
||||
// 数据库操作
|
||||
listDatabases: () => db ? db.listDatabases() : [],
|
||||
createDatabase: (name) => { if (!db) throw new Error("未初始化"); return db.createDatabase(name); },
|
||||
deleteDatabase: (name) => { if (!db) throw new Error("未初始化"); return db.deleteDatabase(name); },
|
||||
renameDatabase: (o, n) => { if (!db) throw new Error("未初始化"); return db.renameDatabase(o, n); },
|
||||
openDatabase: (name) => { if (!db) throw new Error("未初始化"); return db.openDatabase(name); },
|
||||
getBackups: () => db ? db.getBackups() : [],
|
||||
findByDateModified: (d) => db ? db.findByDateModified(d) : null,
|
||||
upsertBackup: (info) => { if (!db) throw new Error("未初始化"); return db.upsertBackup(info); },
|
||||
updateTag: (id, tag) => { if (!db) throw new Error("未初始化"); return db.updateTag(id, tag); },
|
||||
deleteBackup: (id) => { if (!db) throw new Error("未初始化"); return db.deleteBackup(id); },
|
||||
getCurrentDbName: () => db ? db.getCurrentDbName() : null,
|
||||
getCurrentStoragePath: () => db ? db.getCurrentStoragePath() : null,
|
||||
|
||||
// 文件操作
|
||||
getFileStats: (p) => {
|
||||
try {
|
||||
const s = fs.statSync(p);
|
||||
return { size: s.size, mtime: s.mtime.toISOString(), isDirectory: s.isDirectory() };
|
||||
} catch { return null; }
|
||||
},
|
||||
pathExists: (p) => fs.existsSync(p),
|
||||
copyFile: (s, d) => { try { fs.copyFileSync(s, d); return true; } catch { return false; } },
|
||||
deleteFile: (p) => { try { if (fs.existsSync(p)) fs.unlinkSync(p); return true; } catch { return false; } },
|
||||
ensureDir,
|
||||
|
||||
// 压缩
|
||||
compressFolder,
|
||||
copyArchive,
|
||||
isSupportedArchive,
|
||||
getArchiveType,
|
||||
|
||||
// 工具
|
||||
getLatestModifiedTime,
|
||||
formatDateModified,
|
||||
formatDateDisplay,
|
||||
formatFileSize,
|
||||
generateBackupFilename,
|
||||
getBaseName,
|
||||
isNameMatching,
|
||||
pathJoin: (...args) => path.join(...args),
|
||||
pathBasename: (p) => path.basename(p)
|
||||
});
|
||||
470
scripts/renderer.js
Normal file
470
scripts/renderer.js
Normal file
@@ -0,0 +1,470 @@
|
||||
// ============ 全局变量 ============
|
||||
let currentDbName = null;
|
||||
let pendingImport = null;
|
||||
let allBackups = [];
|
||||
|
||||
// ============ 初始化 ============
|
||||
async function init() {
|
||||
try {
|
||||
await window.electronAPI.initDatabase();
|
||||
loadDatabases();
|
||||
setupDropZone();
|
||||
setupEventListeners();
|
||||
} catch (err) {
|
||||
console.error("初始化失败:", err);
|
||||
showToast("初始化失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 工具函数 ============
|
||||
const formatFileSize = (b) => window.electronAPI.formatFileSize(b);
|
||||
const formatDateDisplay = (d) => window.electronAPI.formatDateDisplay(d);
|
||||
|
||||
// ============ 数据库管理 ============
|
||||
function loadDatabases() {
|
||||
const databases = window.electronAPI.listDatabases();
|
||||
const listEl = document.getElementById("databaseList");
|
||||
|
||||
if (databases.length === 0) {
|
||||
listEl.innerHTML = "<div style=\"color: var(--text-muted); text-align: center; padding: 20px; font-size: 11px;\">暂无数据库</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = databases.map(db => `
|
||||
<div class="database-item ${db.name === currentDbName ? "active" : ""}" data-name="${db.name}">
|
||||
<div class="db-details">
|
||||
<div class="db-name">${db.name}</div>
|
||||
<div class="db-info">${db.count} 备份 · ${formatFileSize(db.totalSize)}</div>
|
||||
</div>
|
||||
<div class="db-actions">
|
||||
<button class="db-action-btn" title="重命名" onclick="event.stopPropagation(); showRenameDb('${db.name}')"><i class="fa-solid fa-pen"></i></button>
|
||||
<button class="db-action-btn delete" title="删除" onclick="event.stopPropagation(); confirmDeleteDb('${db.name}')"><i class="fa-solid fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
listEl.querySelectorAll(".database-item").forEach(item => {
|
||||
item.addEventListener("click", () => selectDatabase(item.dataset.name));
|
||||
});
|
||||
}
|
||||
|
||||
function selectDatabase(dbName) {
|
||||
try {
|
||||
window.electronAPI.openDatabase(dbName);
|
||||
currentDbName = dbName;
|
||||
|
||||
document.getElementById("noDatabase").style.display = "none";
|
||||
document.getElementById("databaseContent").style.display = "flex";
|
||||
document.getElementById("searchInput").value = "";
|
||||
|
||||
document.querySelectorAll(".database-item").forEach(item => {
|
||||
item.classList.toggle("active", item.dataset.name === dbName);
|
||||
});
|
||||
|
||||
loadBackups();
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function loadBackups() {
|
||||
allBackups = window.electronAPI.getBackups(); // 已按 date_modified 降序排序
|
||||
renderBackups(allBackups);
|
||||
}
|
||||
|
||||
function renderBackups(backups) {
|
||||
const listEl = document.getElementById("backupList");
|
||||
document.getElementById("backupCount").textContent = `${backups.length} 个备份`;
|
||||
|
||||
if (backups.length === 0) {
|
||||
listEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const icons = { "zip": "fa-file-zipper", "rar": "fa-file-zipper", "7z": "fa-file-zipper", "tar": "fa-file-archive", "gz": "fa-file-archive" };
|
||||
|
||||
listEl.innerHTML = backups.map(b => `
|
||||
<div class="backup-item" data-id="${b.id}" data-search="${b.filename.toLowerCase()} ${(b.tag || "").toLowerCase()}">
|
||||
<div class="backup-icon"><i class="fa-solid ${icons[b.archive_type] || "fa-file"}"></i></div>
|
||||
<div class="backup-details">
|
||||
<div class="backup-name">${b.filename}</div>
|
||||
<div class="backup-meta">
|
||||
<span><i class="fa-regular fa-calendar"></i> ${formatDateDisplay(b.date_modified)}</span>
|
||||
<span><i class="fa-regular fa-file"></i> ${formatFileSize(b.file_size)}</span>
|
||||
<span class="backup-tag ${b.tag ? "" : "empty"}" onclick="showEditTag(${b.id}, '${escapeHtml(b.tag || "")}')">
|
||||
<i class="fa-solid fa-tag"></i> ${b.tag || "标签"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button class="backup-action-btn export" title="导出" onclick="exportBackup(${b.id})"><i class="fa-solid fa-download"></i></button>
|
||||
<button class="backup-action-btn delete" title="删除" onclick="confirmDeleteBackup(${b.id})"><i class="fa-solid fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function filterBackups() {
|
||||
const query = document.getElementById("searchInput").value.toLowerCase().trim();
|
||||
|
||||
if (!query) {
|
||||
renderBackups(allBackups);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allBackups.filter(b =>
|
||||
b.filename.toLowerCase().includes(query) ||
|
||||
(b.tag && b.tag.toLowerCase().includes(query)) ||
|
||||
b.date_modified.includes(query)
|
||||
);
|
||||
|
||||
renderBackups(filtered);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str ? str.replace(/'/g, "\\'").replace(/"/g, "\\\"") : "";
|
||||
}
|
||||
|
||||
// ============ 拖拽处理 ============
|
||||
function setupDropZone() {
|
||||
const dropZone = document.getElementById("dropZone");
|
||||
|
||||
["dragenter", "dragover", "dragleave", "drop"].forEach(e => {
|
||||
dropZone.addEventListener(e, ev => { ev.preventDefault(); ev.stopPropagation(); });
|
||||
document.body.addEventListener(e, ev => { ev.preventDefault(); ev.stopPropagation(); });
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach(e => dropZone.addEventListener(e, () => dropZone.classList.add("drag-over")));
|
||||
["dragleave", "drop"].forEach(e => dropZone.addEventListener(e, () => dropZone.classList.remove("drag-over")));
|
||||
|
||||
dropZone.addEventListener("drop", handleDrop);
|
||||
}
|
||||
|
||||
async function handleDrop(e) {
|
||||
if (!currentDbName) { showToast("请先选择数据库", "warning"); return; }
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length === 0) return;
|
||||
await processImport(files[0].path);
|
||||
}
|
||||
|
||||
async function processImport(filePath) {
|
||||
showLoading("处理中...");
|
||||
|
||||
try {
|
||||
const stats = window.electronAPI.getFileStats(filePath);
|
||||
if (!stats) throw new Error("无法读取文件");
|
||||
|
||||
const originalName = window.electronAPI.pathBasename(filePath);
|
||||
let dateModified, archiveType, sourcePath = filePath, tempFile = null;
|
||||
|
||||
if (stats.isDirectory) {
|
||||
showLoading("压缩文件夹...");
|
||||
const latestTime = window.electronAPI.getLatestModifiedTime(filePath);
|
||||
dateModified = window.electronAPI.formatDateModified(latestTime);
|
||||
archiveType = "zip";
|
||||
|
||||
const dataPath = await window.electronAPI.getDataPath();
|
||||
const tempDir = window.electronAPI.pathJoin(dataPath, "temp");
|
||||
window.electronAPI.ensureDir(tempDir);
|
||||
tempFile = window.electronAPI.pathJoin(tempDir, `temp_${Date.now()}.zip`);
|
||||
await window.electronAPI.compressFolder(filePath, tempFile);
|
||||
sourcePath = tempFile;
|
||||
} else {
|
||||
if (!window.electronAPI.isSupportedArchive(filePath)) {
|
||||
throw new Error("不支持的格式");
|
||||
}
|
||||
dateModified = window.electronAPI.formatDateModified(new Date(stats.mtime));
|
||||
archiveType = window.electronAPI.getArchiveType(filePath);
|
||||
}
|
||||
|
||||
const targetFilename = window.electronAPI.generateBackupFilename(currentDbName, dateModified, archiveType);
|
||||
|
||||
if (!window.electronAPI.isNameMatching(originalName, currentDbName)) {
|
||||
hideLoading();
|
||||
pendingImport = { sourcePath, originalName, dateModified, archiveType, targetFilename, tempFile };
|
||||
showNameMismatchModal(originalName, currentDbName, targetFilename);
|
||||
return;
|
||||
}
|
||||
|
||||
await performImport(sourcePath, originalName, dateModified, archiveType, targetFilename, tempFile);
|
||||
} catch (err) {
|
||||
hideLoading();
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function performImport(sourcePath, originalName, dateModified, archiveType, targetFilename, tempFile) {
|
||||
showLoading("导入中...");
|
||||
|
||||
try {
|
||||
const storagePath = window.electronAPI.getCurrentStoragePath();
|
||||
const targetPath = window.electronAPI.pathJoin(storagePath, targetFilename);
|
||||
|
||||
const existing = window.electronAPI.findByDateModified(dateModified);
|
||||
if (existing) {
|
||||
window.electronAPI.deleteFile(window.electronAPI.pathJoin(storagePath, existing.filename));
|
||||
}
|
||||
|
||||
await window.electronAPI.copyArchive(sourcePath, targetPath);
|
||||
const targetStats = window.electronAPI.getFileStats(targetPath);
|
||||
|
||||
window.electronAPI.upsertBackup({
|
||||
filename: targetFilename,
|
||||
original_name: originalName,
|
||||
date_modified: dateModified,
|
||||
archive_type: archiveType,
|
||||
file_size: targetStats.size
|
||||
});
|
||||
|
||||
if (tempFile) window.electronAPI.deleteFile(tempFile);
|
||||
|
||||
hideLoading();
|
||||
showToast("导入成功", "success");
|
||||
loadBackups();
|
||||
loadDatabases();
|
||||
} catch {
|
||||
hideLoading();
|
||||
showToast("导入失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 模态框 ============
|
||||
function showModal(id) { document.getElementById(id).classList.add("show"); }
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove("show");
|
||||
if (id === "nameMismatchModal" && pendingImport?.tempFile) {
|
||||
window.electronAPI.deleteFile(pendingImport.tempFile);
|
||||
pendingImport = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showNewDbModal() {
|
||||
document.getElementById("dbModalTitle").textContent = "新建数据库";
|
||||
document.getElementById("dbName").value = "";
|
||||
document.getElementById("btnConfirmDb").onclick = confirmCreateDb;
|
||||
showModal("dbModal");
|
||||
document.getElementById("dbName").focus();
|
||||
}
|
||||
|
||||
function confirmCreateDb() {
|
||||
const name = document.getElementById("dbName").value.trim();
|
||||
if (!name) { showToast("请输入名称", "warning"); return; }
|
||||
if (!/^[\w\u4e00-\u9fa5]+$/.test(name)) { showToast("名称包含非法字符", "warning"); return; }
|
||||
|
||||
try {
|
||||
window.electronAPI.createDatabase(name);
|
||||
closeModal("dbModal");
|
||||
showToast("创建成功", "success");
|
||||
loadDatabases();
|
||||
selectDatabase(name);
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function showRenameDb(dbName) {
|
||||
document.getElementById("dbModalTitle").textContent = "重命名";
|
||||
document.getElementById("dbName").value = dbName;
|
||||
document.getElementById("btnConfirmDb").onclick = () => confirmRenameDb(dbName);
|
||||
showModal("dbModal");
|
||||
document.getElementById("dbName").focus();
|
||||
document.getElementById("dbName").select();
|
||||
}
|
||||
|
||||
function confirmRenameDb(oldName) {
|
||||
const newName = document.getElementById("dbName").value.trim();
|
||||
if (!newName) { showToast("请输入名称", "warning"); return; }
|
||||
if (newName === oldName) { closeModal("dbModal"); return; }
|
||||
if (!/^[\w\u4e00-\u9fa5]+$/.test(newName)) { showToast("名称包含非法字符", "warning"); return; }
|
||||
|
||||
try {
|
||||
showLoading("重命名中...");
|
||||
window.electronAPI.renameDatabase(oldName, newName);
|
||||
if (currentDbName === oldName) {
|
||||
currentDbName = newName;
|
||||
window.electronAPI.openDatabase(newName);
|
||||
}
|
||||
hideLoading();
|
||||
closeModal("dbModal");
|
||||
showToast("重命名成功", "success");
|
||||
loadDatabases();
|
||||
loadBackups();
|
||||
} catch (err) {
|
||||
hideLoading();
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteDb(dbName) {
|
||||
showConfirm("删除数据库", `确定删除 "${dbName}"?所有备份将被删除!`, () => {
|
||||
try {
|
||||
window.electronAPI.deleteDatabase(dbName);
|
||||
if (currentDbName === dbName) {
|
||||
currentDbName = null;
|
||||
document.getElementById("noDatabase").style.display = "flex";
|
||||
document.getElementById("databaseContent").style.display = "none";
|
||||
}
|
||||
showToast("已删除", "success");
|
||||
loadDatabases();
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showConfirm(title, message, onConfirm) {
|
||||
document.getElementById("confirmTitle").textContent = title;
|
||||
document.getElementById("confirmMessage").textContent = message;
|
||||
document.getElementById("btnConfirmAction").onclick = () => { closeModal("confirmModal"); onConfirm(); };
|
||||
showModal("confirmModal");
|
||||
}
|
||||
|
||||
function showNameMismatchModal(sourceName, dbName, newFileName) {
|
||||
document.getElementById("sourceFileName").textContent = sourceName;
|
||||
document.getElementById("targetDbName").textContent = dbName;
|
||||
document.getElementById("newFileName").textContent = newFileName;
|
||||
showModal("nameMismatchModal");
|
||||
}
|
||||
|
||||
async function confirmMismatchImport() {
|
||||
closeModal("nameMismatchModal");
|
||||
if (pendingImport) {
|
||||
const { sourcePath, originalName, dateModified, archiveType, targetFilename, tempFile } = pendingImport;
|
||||
pendingImport = null;
|
||||
await performImport(sourcePath, originalName, dateModified, archiveType, targetFilename, tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 标签编辑 ============
|
||||
let editingTagId = null;
|
||||
|
||||
function showEditTag(id, currentTag) {
|
||||
editingTagId = id;
|
||||
document.getElementById("tagInput").value = currentTag || "";
|
||||
showModal("tagModal");
|
||||
document.getElementById("tagInput").focus();
|
||||
}
|
||||
|
||||
function saveTag() {
|
||||
const tag = document.getElementById("tagInput").value.trim();
|
||||
try {
|
||||
window.electronAPI.updateTag(editingTagId, tag);
|
||||
closeModal("tagModal");
|
||||
showToast("已保存", "success");
|
||||
loadBackups();
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function clearTag() {
|
||||
try {
|
||||
window.electronAPI.updateTag(editingTagId, null);
|
||||
closeModal("tagModal");
|
||||
showToast("已清除", "success");
|
||||
loadBackups();
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 导出和删除 ============
|
||||
async function exportBackup(id) {
|
||||
const backup = allBackups.find(b => b.id === id);
|
||||
if (!backup) { showToast("不存在", "error"); return; }
|
||||
|
||||
const exportDir = await window.electronAPI.selectExportDir();
|
||||
if (!exportDir) return;
|
||||
|
||||
const storagePath = window.electronAPI.getCurrentStoragePath();
|
||||
const sourcePath = window.electronAPI.pathJoin(storagePath, backup.filename);
|
||||
const targetPath = window.electronAPI.pathJoin(exportDir, backup.filename);
|
||||
|
||||
try {
|
||||
showLoading("导出中...");
|
||||
await window.electronAPI.copyArchive(sourcePath, targetPath);
|
||||
hideLoading();
|
||||
showToast("导出成功", "success");
|
||||
window.electronAPI.showInFolder(targetPath);
|
||||
} catch {
|
||||
hideLoading();
|
||||
showToast("导出失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteBackup(id) {
|
||||
showConfirm("删除备份", "确定删除?不可恢复!", () => {
|
||||
try {
|
||||
window.electronAPI.deleteBackup(id);
|
||||
showToast("已删除", "success");
|
||||
loadBackups();
|
||||
loadDatabases();
|
||||
} catch (err) {
|
||||
showToast(err.message, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 工具函数 ============
|
||||
function showToast(message, type = "success") {
|
||||
const toast = document.getElementById("toast");
|
||||
const icons = { success: "fa-check", error: "fa-xmark", warning: "fa-exclamation" };
|
||||
|
||||
toast.className = "toast " + type;
|
||||
document.getElementById("toastIcon").className = "toast-icon fa-solid " + (icons[type] || "fa-check");
|
||||
document.getElementById("toastMessage").textContent = message;
|
||||
toast.classList.add("show");
|
||||
|
||||
setTimeout(() => toast.classList.remove("show"), 2500);
|
||||
}
|
||||
|
||||
function showLoading(text = "处理中...") {
|
||||
document.getElementById("loadingText").textContent = text;
|
||||
document.getElementById("loadingOverlay").classList.add("show");
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
document.getElementById("loadingOverlay").classList.remove("show");
|
||||
}
|
||||
|
||||
// ============ 事件监听 ============
|
||||
function setupEventListeners() {
|
||||
document.getElementById("btnConfirmImport").addEventListener("click", confirmMismatchImport);
|
||||
document.getElementById("btnConfirmTag").addEventListener("click", saveTag);
|
||||
document.getElementById("btnClearTag").addEventListener("click", clearTag);
|
||||
|
||||
document.querySelectorAll(".modal-overlay").forEach(overlay => {
|
||||
overlay.addEventListener("click", e => {
|
||||
const modal = e.target.closest(".modal");
|
||||
if (modal) closeModal(modal.id);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "Escape") {
|
||||
const openModal = document.querySelector(".modal.show");
|
||||
if (openModal) closeModal(openModal.id);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("dbName").addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") document.getElementById("btnConfirmDb").click();
|
||||
});
|
||||
|
||||
document.getElementById("tagInput").addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") saveTag();
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
// 暴露全局函数
|
||||
window.showNewDbModal = showNewDbModal;
|
||||
window.showRenameDb = showRenameDb;
|
||||
window.confirmDeleteDb = confirmDeleteDb;
|
||||
window.showEditTag = showEditTag;
|
||||
window.exportBackup = exportBackup;
|
||||
window.confirmDeleteBackup = confirmDeleteBackup;
|
||||
window.closeModal = closeModal;
|
||||
window.filterBackups = filterBackups;
|
||||
771
styles/main.css
Normal file
771
styles/main.css
Normal file
@@ -0,0 +1,771 @@
|
||||
/* ============ 紧凑奶白啡主题 ============ */
|
||||
:root {
|
||||
--bg-primary: #faf7f2;
|
||||
--bg-secondary: #f3efe8;
|
||||
--bg-card: #ffffff;
|
||||
--bg-hover: #f0ebe3;
|
||||
|
||||
--text-primary: #3d3028;
|
||||
--text-secondary: #6b5d4d;
|
||||
--text-muted: #9a8b7a;
|
||||
|
||||
--accent: #a67c52;
|
||||
--accent-light: #d4b896;
|
||||
|
||||
--success: #5a9a5a;
|
||||
--warning: #c4a04c;
|
||||
--danger: #b86b6b;
|
||||
|
||||
--border: rgba(166, 124, 82, 0.12);
|
||||
--shadow: 0 1px 4px rgba(61, 48, 40, 0.08);
|
||||
|
||||
--radius: 6px;
|
||||
--transition: all 0.15s ease;
|
||||
|
||||
--titlebar-h: 32px;
|
||||
--sidebar-w: 180px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============ 标题栏 ============ */
|
||||
.titlebar {
|
||||
height: var(--titlebar-h);
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.titlebar-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.titlebar-icon {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.titlebar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.titlebar-controls {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.titlebar-btn {
|
||||
width: 36px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.titlebar-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.titlebar-btn.close:hover {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ============ 主布局 ============ */
|
||||
.container {
|
||||
display: flex;
|
||||
height: calc(100vh - var(--titlebar-h));
|
||||
}
|
||||
|
||||
/* ============ 侧边栏 ============ */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.database-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.database-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.database-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.database-item.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.database-item.active .db-info {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.db-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.db-name {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.db-info {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.db-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.database-item:hover .db-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.db-action-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: var(--bg-card);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.db-action-btn:hover {
|
||||
background: var(--accent-light);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.db-action-btn.delete:hover {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ============ 主内容区 ============ */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-database {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-database-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.no-database h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.no-database p {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.database-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============ 工具栏 ============ */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 10px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.search-box i {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 7px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toolbar-info {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============ 拖拽区域 ============ */
|
||||
.drop-zone {
|
||||
background: var(--bg-card);
|
||||
border: 1.5px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.drag-over {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
font-size: 18px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.drop-zone span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ============ 备份列表 ============ */
|
||||
.backup-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.backup-list:empty::after {
|
||||
content: '暂无备份';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: var(--transition);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.backup-item:hover {
|
||||
border-color: var(--accent-light);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.backup-item.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-light));
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.backup-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-name {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.backup-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.backup-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.backup-tag {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.backup-tag:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.backup-tag.empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.backup-action-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.backup-action-btn.export {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.backup-action-btn.export:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.backup-action-btn.delete {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.backup-action-btn.delete:hover {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ============ 按钮 ============ */
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ============ 模态框 ============ */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(61, 48, 40, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
animation: modalIn 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-sm {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
/* ============ 表单 ============ */
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============ 文件名不匹配 ============ */
|
||||
.mismatch-info {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mismatch-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mismatch-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mismatch-value {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mismatch-value.highlight {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============ Toast ============ */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(60px);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 2000;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toast.success .toast-icon {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.toast.error .toast-icon {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.toast.warning .toast-icon {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* ============ 加载 ============ */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(250, 247, 242, 0.9);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.loading-overlay.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid var(--bg-secondary);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============ 滚动条 ============ */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-hover);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
Reference in New Issue
Block a user