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