Initial commit

This commit is contained in:
TYt50
2026-02-01 15:12:59 +08:00
commit 783080af35
10 changed files with 7039 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
electron-data/
data/
*.log
.DS_Store
.vscode/

22
README.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View 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
View 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
View 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
View 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);
}