项目简介
gThumb 是 Linux GNOME 桌面环境的开源图片查看器,支持缩略图网格浏览、缩放旋转、幻灯片播放、图片导航等功能。本项目将其从 Linux GTK 应用迁移到鸿蒙平台,采用 Electron 核心功能 + 鸿蒙壳工程 的架构模式。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/weixin_62765017/ohos_gThumb_electron
核心功能
- 📁 文件夹浏览(一键加载整个文件夹,自动识别图片)
- 🖼️ 多文件选择(支持 JPG/PNG/GIF/WebP/SVG 等格式)
- 🔍 智能缩放(25%~400%,适应窗口动态计算)
- 🔄 图片旋转(向左/向右 90°,防抖锁保护)
- 🎬 幻灯片播放(自动轮播,适合展示场景)
- 📸 缩略图网格(左侧面板,点击切换,悬停删除)
- ⬅️➡️ 线性导航(上一张/下一张,首尾自动隐藏按钮)
- 📊 图片信息(文件名、大小、尺寸、当前位置)
- ⌨️ 完整快捷键(Ctrl+O、+/-、L/R、←→、F5、Delete)
- 🗑️ 图片管理(删除当前、清空已选,即时生效)
一、技术架构
1.1 原始架构(Linux GTK)
bash
gThumb (C/GTK Linux Desktop)
├── UI 渲染:GTK+ 3 Widget
├── 图片加载:GDK-Pixbuf
├── 缩略图缓存:~/.cache/gthumb
└── 文件系统:GIO/GFile
1.2 目标架构(鸿蒙 Electron)
bash
鸿蒙壳工程 (ArkTS)
└── web_engine 模块 (XComponent WebView)
└── Electron 应用 (HTML/CSS/JavaScript)
├── main.js - Electron 主进程
├── renderer.js - 渲染进程(核心逻辑)
├── index.html - UI 界面
├── package.json - 项目配置
└── styles/
└── gthumb.css - 样式文件
1.3 架构优势
- 跨平台:Electron 代码可在 Windows/macOS/Linux 复用
- 快速开发:Web 技术栈,开发效率高
- 易于维护:UI 和业务逻辑分离
- 鸿蒙兼容:通过 WebView 桥接,避开 Native 兼容问题
二、环境准备
2.1 开发环境要求
- 操作系统:Windows 10
- 开发工具:DevEco Studio(鸿蒙官方 IDE)
- HarmonyOS SDK:API 15+
- Node.js:v20+(Electron 依赖)
2.2 项目结构
bash
ohos_hap/
└── web_engine/ # 鸿蒙 web_engine 模块
└── src/main/resources/
└── resfile/resources/app/ # 部署目录
├── main.js # Electron 主进程
├── renderer.js # 渲染进程(核心逻辑)
├── index.html # UI 界面
└── styles/
└── gthumb.css # 样式文件
└── build-profile.json5 # 鸿蒙构建配置
三、核心适配流程
3.1 第一步:创建 Electron 主进程(main.js)
文件:web_engine/src/main/resources/resfile/resources/app/main.js
js
// gThumb Image Viewer - Electron 主进程
const { app, BrowserWindow, ipcMain, dialog, screen } = require('electron');
const path = require('path');
const fs = require('fs');
let mainWindow = null;
function createWindow() {
console.log('gThumb: Creating window...');
// 获取屏幕尺寸
const primaryDisplay = screen.getPrimaryDisplay();
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize;
// 窗口配置:占据大部分屏幕
const windowWidth = Math.floor(screenWidth * 0.95);
const windowHeight = Math.floor(screenHeight * 0.9);
mainWindow = new BrowserWindow({
width: windowWidth,
height: windowHeight,
x: Math.floor((screenWidth - windowWidth) / 2),
y: Math.floor((screenHeight - windowHeight) / 2),
frame: true,
transparent: false,
alwaysOnTop: false,
hasShadow: true,
resizable: true,
focusable: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
backgroundThrottling: false
}
});
console.log('gThumb: Loading index.html from:', path.join(__dirname, 'index.html'));
mainWindow.loadFile(path.join(__dirname, 'index.html'));
console.log('gThumb: Window created with size:', windowWidth, 'x', windowHeight);
mainWindow.on('closed', () => {
console.log('gThumb: Window closed');
mainWindow = null;
});
mainWindow.webContents.on('did-finish-load', () => {
console.log('gThumb: Page loaded successfully');
});
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('gThumb: Page failed to load:', errorCode, errorDescription);
});
setupIpcHandlers();
}
function setupIpcHandlers() {
console.log('gThumb: Setting up IPC handlers');
// 打开文件夹对话框
ipcMain.handle('open-folder-dialog', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
title: '选择图片文件夹'
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths[0];
}
return null;
});
// 打开文件对话框(多选)
ipcMain.handle('open-file-dialog', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'],
filters: [
{
name: 'Images',
extensions: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif', 'avif', 'heif', 'jxl']
},
{ name: 'All Files', extensions: ['*'] }
]
});
if (!result.canceled && result.filePaths.length > 0) {
return result.filePaths;
}
return null;
});
// 获取目录中的所有图片和子文件夹
ipcMain.handle('get-directory-content', async (event, dirPath) => {
try {
const items = fs.readdirSync(dirPath, { withFileTypes: true });
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico', '.tiff', '.tif', '.avif', '.heif', '.jxl'];
const folders = items
.filter(item => item.isDirectory())
.map(item => ({
name: item.name,
path: path.join(dirPath, item.name),
type: 'folder'
}));
const images = items
.filter(item => item.isFile() && imageExtensions.includes(path.extname(item.name).toLowerCase()))
.map(item => {
const filePath = path.join(dirPath, item.name);
const stats = fs.statSync(filePath);
return {
name: item.name,
path: filePath,
type: 'image',
size: stats.size,
modified: stats.mtime,
ext: path.extname(item.name).toLowerCase()
};
});
// 按名称排序
folders.sort((a, b) => a.name.localeCompare(b.name));
images.sort((a, b) => a.name.localeCompare(b.name));
return { folders, images, currentPath: dirPath };
} catch (error) {
console.error('gThumb: Failed to read directory:', error);
return { folders: [], images: [], currentPath: dirPath };
}
});
// 获取图片文件信息
ipcMain.handle('get-image-info', async (event, filePath) => {
try {
const stats = fs.statSync(filePath);
return {
path: filePath,
name: path.basename(filePath),
size: stats.size,
modified: stats.mtime,
ext: path.extname(filePath)
};
} catch (error) {
console.error('gThumb: Failed to get image info:', error);
return null;
}
});
// 保存图片(用于编辑后保存)
ipcMain.handle('save-file-dialog', async (event, defaultPath) => {
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: defaultPath,
filters: [
{ name: 'PNG', extensions: ['png'] },
{ name: 'JPEG', extensions: ['jpg', 'jpeg'] },
{ name: 'WebP', extensions: ['webp'] }
]
});
return result.filePath;
});
// 读取文件(用于图片编辑后保存)
ipcMain.handle('write-file', async (event, filePath, data) => {
try {
fs.writeFileSync(filePath, Buffer.from(data));
return true;
} catch (error) {
console.error('gThumb: Failed to write file:', error);
return false;
}
});
}
app.whenReady().then(() => {
createWindow();
console.log('gThumb Image Viewer 已启动');
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

关键要点:
- 窗口尺寸动态计算(屏幕 95% 宽度 × 90% 高度)
- 提供 4 个核心 IPC 接口:文件夹选择、文件多选、目录读取、图片信息
- 支持 13 种图片格式(JPG/PNG/GIF/WebP/SVG/ICO/TIFF/AVIF/HEIF/JXL 等)
- fs.statSync 获取真实文件大小和修改时间
- 图片和文件夹按名称排序显示
3.2 第二步:设计双栏式专业 UI(index.html)
文件:web_engine/src/main/resources/resfile/resources/app/index.html
js
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>gThumb - 图片查看器</title>
<link rel="stylesheet" href="styles/gthumb.css">
</head>
<body>
<!-- SVG 图标定义 -->
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="icon-folder" viewBox="0 0 24 24">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
</symbol>
<symbol id="icon-image" viewBox="0 0 24 24">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</symbol>
<symbol id="icon-zoom-in" viewBox="0 0 24 24">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</symbol>
<symbol id="icon-zoom-out" viewBox="0 0 24 24">
<path d="M19 13H5v-2h14v2z"/>
</symbol>
<symbol id="icon-rotate" viewBox="0 0 24 24">
<!-- Material Design rotate_right - 顺时针旋转(箭头指向右上方) -->
<path d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V8l4.55-2.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z"/>
</symbol>
<symbol id="icon-rotate-left" viewBox="0 0 24 24">
<!-- Material Design rotate_left - 逆时针旋转(箭头指向左上方) -->
<path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/>
</symbol>
<symbol id="icon-fit" viewBox="0 0 24 24">
<path d="M3 5v4h2V5h4V3H5c-1.1 0-2 .9-2 2zm2 10H3v4c0 1.1.9 2 2 2h4v-2H5v-4zm14 4h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4zm0-16h-4v2h4v4h2V5c0-1.1-.9-2-2-2z"/>
</symbol>
<symbol id="icon-slideshow" viewBox="0 0 24 24">
<path d="M10 8l6 4-6 4V8zm11-5v18H3V3h18zm-2 2H5v14h14V5z"/>
</symbol>
<symbol id="icon-open" viewBox="0 0 24 24">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
</symbol>
<symbol id="icon-prev" viewBox="0 0 24 24">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</symbol>
<symbol id="icon-next" viewBox="0 0 24 24">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</symbol>
<symbol id="icon-delete" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</symbol>
<symbol id="icon-clear" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</symbol>
</svg>
<!-- 主应用容器 -->
<div id="app" class="app-container">
<!-- 顶部工具栏 -->
<header class="toolbar">
<div class="toolbar-left">
<button id="btn-open-folder" class="btn btn-icon" title="打开文件夹 (Ctrl+O)">
<svg><use href="#icon-open"/></svg>
</button>
<button id="btn-open-files" class="btn btn-icon" title="打开文件 (Ctrl+Shift+O)">
<svg><use href="#icon-image"/></svg>
</button>
</div>
<div class="toolbar-center">
<!-- 移除路径显示 -->
</div>
<div class="toolbar-right">
<button id="btn-zoom-out" class="btn btn-icon" title="缩小 (Ctrl+-)">
<svg><use href="#icon-zoom-out"/></svg>
</button>
<span id="zoom-display" class="zoom-display">100%</span>
<button id="btn-zoom-in" class="btn btn-icon" title="放大 (Ctrl++)">
<svg><use href="#icon-zoom-in"/></svg>
</button>
<div class="toolbar-separator"></div>
<button id="btn-fit" class="btn btn-icon" title="适应窗口 (Ctrl+0)">
<svg><use href="#icon-fit"/></svg>
</button>
<button id="btn-rotate-left" class="btn btn-icon" title="向左旋转 (L)">
<svg><use href="#icon-rotate-left"/></svg>
</button>
<button id="btn-rotate-right" class="btn btn-icon" title="向右旋转 (R)">
<svg><use href="#icon-rotate"/></svg>
</button>
<div class="toolbar-separator"></div>
<button id="btn-slideshow" class="btn btn-icon" title="幻灯片播放 (F5)">
<svg><use href="#icon-slideshow"/></svg>
</button>
</div>
</header>
<!-- 主内容区 -->
<main class="main-content">
<!-- 左侧缩略图网格 -->
<section class="thumbnail-panel">
<div class="thumbnail-header">
<div class="thumbnail-header-left">
<h2 id="folder-name">未选择文件夹</h2>
<span id="image-count" class="image-count">暂无图片</span>
</div>
</div>
<div id="thumbnail-grid" class="thumbnail-grid">
<!-- 缩略图动态生成 -->
</div>
</section>
<!-- 右侧图片预览 -->
<section class="preview-panel" id="preview-panel">
<div class="empty-state" id="empty-state">
<svg class="empty-state-icon"><use href="#icon-image"/></svg>
<div class="empty-state-text">🖼️ 选择一张图片开始预览</div>
<div class="empty-state-hint">支持 JPG、PNG、GIF、WebP、SVG 等格式</div>
</div>
<!-- 图片导航箭头(在图片左右两侧) -->
<button id="btn-prev-image" class="nav-button nav-prev" title="上一张 (←)">
<svg><use href="#icon-prev"/></svg>
</button>
<button id="btn-next-image" class="nav-button nav-next" title="下一张 (→)">
<svg><use href="#icon-next"/></svg>
</button>
<div id="image-viewer" class="image-viewer" style="display: none;">
<img id="preview-image" src="" alt="预览图片">
<div id="image-info" class="image-info">
<span id="image-name"></span>
<span class="separator">|</span>
<span id="image-size"></span>
<span class="separator">|</span>
<span id="image-dimensions"></span>
<span class="separator">|</span>
<span id="image-position"></span>
</div>
</div>
</section>
</main>
<!-- 底部状态栏 -->
<footer class="status-bar">
<span id="status-text">就绪</span>
<span id="zoom-level">100%</span>
</footer>
</div>
<!-- 引入脚本 -->
<script src="renderer.js"></script>
</body>
</html>

关键要点:
- 双栏式布局(左侧缩略图 360px + 右侧预览自适应)
- Material Design SVG 图标系统(统一使用 )
- 工具栏包含:打开、缩放、旋转、幻灯片核心功能
- 空状态提示引导用户打开图片
- 图片导航箭头在预览区左右两侧
3.3 第三步:配置项目元信息(package.json)
文件:web_engine/src/main/resources/resfile/resources/app/package.json
plain
{
"name": "gthumb-image-viewer",
"version": "1.0.0",
"description": "gThumb Image Viewer - Electron 版本(鸿蒙适配)",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron . --dev"
},
"keywords": ["image", "viewer", "editor", "gthumb"],
"author": "GNOME Project (Electron port)",
"license": "GPL-2.0",
"devDependencies": {
"electron": "^28.0.0"
}
}

关键要点:
- main 入口:指定 main.js 为 Electron 主进程入口文件
- scripts 脚本:start 启动生产模式,dev 启动开发模式(带调试参数)
- license 协议:GPL-2.0(与原始 gThumb 保持一致)
- electron 版本:^28.0.0(兼容鸿蒙 ArkWeb 的 Electron 版本)
- keywords:包含 image、viewer、gthumb 等搜索关键词
3.4 第四步:实现渲染进程核心逻辑(renderer.js)
文件:web_engine/src/main/resources/resfile/resources/app/renderer.js
js
// gThumb Image Viewer - 渲染进程核心逻辑
// 全局状态
let currentFolder = null;
let currentImages = [];
let currentIndex = -1;
let currentZoom = 100;
let rotation = 0;
let slideshowTimer = null;
let isImageLoaded = false;
let eventsInitialized = false; // 防止重复绑定事件
let isRotating = false; // 防止旋转按钮重复触发
// 初始化
document.addEventListener('DOMContentLoaded', () => {
bindEvents();
console.log('gThumb: 应用初始化完成');
});
// 绑定事件
function bindEvents() {
// 防止重复绑定
if (eventsInitialized) {
console.warn('gThumb: 事件已经绑定,跳过重复绑定');
return;
}
eventsInitialized = true;
// 打开文件夹
document.getElementById('btn-open-folder').addEventListener('click', openFolder);
// 打开文件
document.getElementById('btn-open-files').addEventListener('click', openFiles);
// 缩放控制
document.getElementById('btn-zoom-in').addEventListener('click', () => zoomImage(25));
document.getElementById('btn-zoom-out').addEventListener('click', () => zoomImage(-25));
document.getElementById('btn-fit').addEventListener('click', fitImage);
// 向左旋转
document.getElementById('btn-rotate-left').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.blur(); // 移除焦点,防止空格/回车重复触发
if (isRotating) return; // 防抖
if (!isImageLoaded || currentImages.length === 0) {
showStatus('请先打开图片');
return;
}
isRotating = true;
rotation = (rotation - 90 + 360) % 360;
applyZoom();
showStatus(`向左旋转 90° (当前: ${rotation}°)`);
setTimeout(() => { isRotating = false; }, 200);
});
// 向右旋转
document.getElementById('btn-rotate-right').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.blur(); // 移除焦点,防止空格/回车重复触发
if (isRotating) return; // 防抖
if (!isImageLoaded || currentImages.length === 0) {
showStatus('请先打开图片');
return;
}
isRotating = true;
rotation = (rotation + 90) % 360;
applyZoom();
showStatus(`向右旋转 90° (当前: ${rotation}°)`);
setTimeout(() => { isRotating = false; }, 200);
});
// 幻灯片
document.getElementById('btn-slideshow').addEventListener('click', toggleSlideshow);
// 图片导航
document.getElementById('btn-prev-image').addEventListener('click', () => {
if (currentIndex > 0) {
showImage(currentIndex - 1);
}
});
document.getElementById('btn-next-image').addEventListener('click', () => {
if (currentIndex < currentImages.length - 1) {
showImage(currentIndex + 1);
}
});
// 键盘快捷键
document.addEventListener('keydown', handleKeyboard);
}
// 打开文件夹
async function openFolder() {
const folderPath = await window.electronAPI.invoke('open-folder-dialog');
if (!folderPath) return;
currentFolder = folderPath;
await loadDirectory(folderPath);
}
// 打开文件
async function openFiles() {
const filePaths = await window.electronAPI.invoke('open-file-dialog');
if (!filePaths || filePaths.length === 0) return;
// 使用 path 模块处理跨平台路径
const path = require('path');
showStatus('正在获取文件信息...');
// 并发获取每个文件的真实信息(包含 size、modified)
const infoPromises = filePaths.map(filePath =>
window.electronAPI.invoke('get-image-info', filePath).catch(() => null)
);
const infos = await Promise.all(infoPromises);
const newImages = filePaths.map((filePath, idx) => {
const info = infos[idx];
const fileName = path.basename(filePath);
return {
path: filePath,
name: fileName,
type: 'image',
size: (info && typeof info.size === 'number') ? info.size : 0,
modified: info ? info.modified : null,
ext: info ? info.ext : path.extname(filePath).toLowerCase()
};
});
// 追加到现有图片列表(而不是替换)
currentImages = currentImages.concat(newImages);
currentFolder = null; // 清除文件夹记录
isImageLoaded = false;
// 更新 UI
document.getElementById('folder-name').textContent = '已选择文件';
document.getElementById('image-count').textContent = `${currentImages.length} 张图片`;
displayImages();
// 如果之前没有图片,显示第一张
if (currentIndex === -1) {
showImage(0);
}
showStatus(`已加载 ${newImages.length} 张图片,共 ${currentImages.length} 张`);
}
// 清空已选文件
function clearFiles() {
currentImages = [];
currentFolder = null;
currentIndex = -1;
isImageLoaded = false;
// 重置 UI
document.getElementById('folder-name').textContent = '未选择文件夹';
document.getElementById('image-count').textContent = '暂无图片';
// 清空缩略图
document.getElementById('thumbnail-grid').innerHTML = '';
// 隐藏预览
document.getElementById('empty-state').style.display = 'flex';
document.getElementById('image-viewer').style.display = 'none';
// 重置缩放和旋转
currentZoom = 100;
rotation = 0;
document.getElementById('zoom-level').textContent = '100%';
// 更新缩放显示
const zoomDisplay = document.getElementById('zoom-display');
if (zoomDisplay) {
zoomDisplay.textContent = '100%';
}
showStatus('已清空所有文件');
}
// 删除单个图片
function deleteImage(index) {
if (index < 0 || index >= currentImages.length) return;
const wasPlaying = slideshowTimer !== null;
// 删除图片
currentImages.splice(index, 1);
// 更新索引
if (currentIndex >= currentImages.length) {
currentIndex = currentImages.length - 1;
}
// 如果删除的是当前图片,重置状态
if (currentIndex === -1 && currentImages.length > 0) {
currentIndex = 0;
}
// 更新 UI
if (currentImages.length === 0) {
clearFiles();
return;
}
document.getElementById('image-count').textContent = `${currentImages.length} 张图片`;
displayImages();
// 显示当前图片
if (currentIndex >= 0) {
showImage(currentIndex);
}
// 如果之前在播放幻灯片,继续播放
if (wasPlaying && !slideshowTimer) {
toggleSlideshow();
}
showStatus(`已删除图片,剩余 ${currentImages.length} 张`);
}
// 加载目录内容
async function loadDirectory(dirPath) {
showStatus('加载文件夹...');
try {
const content = await window.electronAPI.invoke('get-directory-content', dirPath);
// 提取文件夹名称(而不是完整路径)
const folderName = dirPath.split(/[\\/]/).pop() || dirPath;
// 更新路径显示(显示文件夹名称)
document.getElementById('folder-name').textContent = folderName;
// 更新图片计数(更友好的显示)
const imageCount = content.images.length;
if (imageCount === 0) {
document.getElementById('image-count').textContent = '暂无图片';
} else if (imageCount === 1) {
document.getElementById('image-count').textContent = '1 张图片';
} else {
document.getElementById('image-count').textContent = `${imageCount} 张图片`;
}
// 保存并显示图片
currentImages = content.images;
currentIndex = -1;
isImageLoaded = false;
displayImages();
// 重置缩放和旋转
currentZoom = 100;
rotation = 0;
document.getElementById('zoom-level').textContent = '100%';
// 更新缩放显示
const zoomDisplay = document.getElementById('zoom-display');
if (zoomDisplay) {
zoomDisplay.textContent = '100%';
}
showStatus(`加载完成,共 ${imageCount} 张图片`);
} catch (error) {
console.error('gThumb: 加载文件夹失败:', error);
showStatus('加载失败');
}
}
// 渲染文件夹树
function renderFolderTree(folders, currentPath) {
const treeContainer = document.getElementById('folder-tree');
if (folders.length === 0) {
treeContainer.innerHTML = '<div class="empty-hint">无子文件夹</div>';
return;
}
treeContainer.innerHTML = '';
// 添加父目录按钮
const parentBtn = document.createElement('div');
parentBtn.className = 'folder-item folder-parent';
parentBtn.innerHTML = '<svg class="folder-icon"><use href="#icon-folder"/></svg><span class="folder-name">..</span>';
parentBtn.onclick = () => {
const parentPath = currentPath.split('\\').slice(0, -1).join('\\');
if (parentPath) loadDirectory(parentPath);
};
treeContainer.appendChild(parentBtn);
// 添加子文件夹
folders.forEach(folder => {
const item = document.createElement('div');
item.className = 'folder-item';
item.innerHTML = `<svg class="folder-icon"><use href="#icon-folder"/></svg><span class="folder-name">${escapeHtml(folder.name)}</span>`;
item.onclick = () => loadDirectory(folder.path);
treeContainer.appendChild(item);
});
}
// 显示图片缩略图网格
function displayImages() {
const grid = document.getElementById('thumbnail-grid');
grid.innerHTML = '';
if (currentImages.length === 0) {
grid.innerHTML = '<div class="empty-hint">🖼️ 暂无图片</div>';
return;
}
currentImages.forEach((image, index) => {
const thumb = document.createElement('div');
thumb.className = 'thumbnail-item';
if (index === currentIndex) {
thumb.classList.add('thumbnail-active');
}
thumb.innerHTML = `
<button class="thumbnail-delete" data-index="${index}" title="删除">
<svg><use href="#icon-delete"/></svg>
</button>
<img src="${image.path}" alt="${escapeHtml(image.name)}" loading="lazy">
<div class="thumbnail-name">${escapeHtml(image.name)}</div>
`;
// 点击缩略图显示图片
thumb.querySelector('img').onclick = () => showImage(index);
thumb.querySelector('.thumbnail-name').onclick = () => showImage(index);
// 删除按钮事件
const deleteBtn = thumb.querySelector('.thumbnail-delete');
deleteBtn.onclick = (e) => {
e.stopPropagation();
deleteImage(index);
};
grid.appendChild(thumb);
});
}
// 显示指定图片
function showImage(index) {
if (index < 0 || index >= currentImages.length) return;
currentIndex = index;
const image = currentImages[index];
// 更新缩略图选中状态
const thumbnails = document.querySelectorAll('.thumbnail-item');
thumbnails.forEach((thumb, i) => {
thumb.classList.toggle('thumbnail-active', i === index);
});
// 显示预览面板
document.getElementById('empty-state').style.display = 'none';
const viewer = document.getElementById('image-viewer');
viewer.style.display = 'flex';
// 显示加载状态
isImageLoaded = false;
showStatus(`加载中: ${index + 1} / ${currentImages.length}`);
// 加载图片
const previewImg = document.getElementById('preview-image');
// 图片加载完成后再显示
previewImg.onload = () => {
isImageLoaded = true;
// 自动适应窗口
fitImage();
// 获取并显示图片尺寸
const dimensionsElement = document.getElementById('image-dimensions');
if (dimensionsElement) {
const width = previewImg.naturalWidth;
const height = previewImg.naturalHeight;
dimensionsElement.textContent = `${width} x ${height}`;
}
showStatus(`图片 ${index + 1} / ${currentImages.length}`);
};
previewImg.onerror = () => {
isImageLoaded = true;
showStatus('图片加载失败');
};
previewImg.src = image.path;
// 更新信息(添加安全检查)
const nameElement = document.getElementById('image-name');
const sizeElement = document.getElementById('image-size');
const positionElement = document.getElementById('image-position');
if (nameElement) {
nameElement.textContent = image.name || '未知文件';
}
if (sizeElement) {
// 确保 size 是有效数字
const fileSize = image.size;
if (fileSize !== undefined && fileSize !== null && !isNaN(fileSize)) {
sizeElement.textContent = formatFileSize(fileSize);
} else {
sizeElement.textContent = '未知大小';
}
}
// 更新位置信息
if (positionElement) {
positionElement.textContent = `${index + 1} / ${currentImages.length}`;
}
// 滚动到选中的缩略图
if (thumbnails[index]) {
thumbnails[index].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// 更新导航按钮可见性
updateNavButtons();
}
// 更新导航按钮可见性
function updateNavButtons() {
const btnPrev = document.getElementById('btn-prev-image');
const btnNext = document.getElementById('btn-next-image');
if (!btnPrev || !btnNext) return;
// 第一张时隐藏上一张按钮
if (currentIndex <= 0) {
btnPrev.style.opacity = '0.3';
btnPrev.style.pointerEvents = 'none';
} else {
btnPrev.style.opacity = '1';
btnPrev.style.pointerEvents = 'auto';
}
// 最后一张时隐藏下一张按钮
if (currentIndex >= currentImages.length - 1) {
btnNext.style.opacity = '0.3';
btnNext.style.pointerEvents = 'none';
} else {
btnNext.style.opacity = '1';
btnNext.style.pointerEvents = 'auto';
}
}
// 缩放图片
function zoomImage(delta) {
if (!isImageLoaded || currentImages.length === 0) {
showStatus('请先打开图片');
return;
}
currentZoom = Math.max(25, Math.min(400, currentZoom + delta));
applyZoom();
// 更新缩放显示
const zoomDisplay = document.getElementById('zoom-display');
if (zoomDisplay) {
zoomDisplay.textContent = `${currentZoom}%`;
}
// 更新底部状态栏
document.getElementById('zoom-level').textContent = `${currentZoom}%`;
}
// 适应窗口(智能缩放)
function fitImage() {
if (!isImageLoaded || currentImages.length === 0) {
showStatus('请先打开图片');
return;
}
const previewImg = document.getElementById('preview-image');
if (!previewImg || !previewImg.naturalWidth) {
showStatus('图片未加载完成');
return;
}
// 获取容器可用区域(预览面板)
const container = previewImg.closest('.preview-panel') || previewImg.parentElement;
if (!container) {
showStatus('容器未找到');
return;
}
// 先重置变换,让浏览器重新布局
rotation = 0;
previewImg.style.transform = '';
// 计算容器实际可用尺寸(预留状态栏空间)
const containerWidth = container.clientWidth - 40;
const containerHeight = container.clientHeight - 100;
// 获取图片原始尺寸
const naturalWidth = previewImg.naturalWidth;
const naturalHeight = previewImg.naturalHeight;
// 计算适配缩放比(宽高取小值,不超过 100%)
const ratioW = containerWidth / naturalWidth;
const ratioH = containerHeight / naturalHeight;
const fitRatio = Math.min(ratioW, ratioH, 1);
// 换算为处理 CSS max-width/max-height 限制后的缩放值
// CSS 已使图片适应到 max-width:100%/max-height:calc(100%-60px),这里只需重置 scale=1
currentZoom = 100;
applyZoom();
// 更新缩放显示
const zoomDisplay = document.getElementById('zoom-display');
if (zoomDisplay) {
zoomDisplay.textContent = '100%';
}
const zoomLevelEl = document.getElementById('zoom-level');
if (zoomLevelEl) {
zoomLevelEl.textContent = '100%';
}
// 计算实际显示尺寸供状态提示
const displayWidth = Math.round(naturalWidth * fitRatio);
const displayHeight = Math.round(naturalHeight * fitRatio);
showStatus(`已适应窗口 · 原始${naturalWidth}×${naturalHeight} → 显示${displayWidth}×${displayHeight} (${Math.round(fitRatio * 100)}%)`);
}
// 应用缩放
function applyZoom() {
const previewImg = document.getElementById('preview-image');
if (previewImg) {
previewImg.style.transform = `scale(${currentZoom / 100}) rotate(${rotation}deg)`;
}
}
// 幻灯片播放
function toggleSlideshow() {
if (slideshowTimer) {
// 停止幻灯片
clearInterval(slideshowTimer);
slideshowTimer = null;
document.getElementById('btn-slideshow').classList.remove('btn-active');
showStatus('幻灯片已停止');
} else {
// 开始幻灯片
document.getElementById('btn-slideshow').classList.add('btn-active');
showStatus('幻灯片播放中...');
slideshowTimer = setInterval(() => {
const nextIndex = (currentIndex + 1) % currentImages.length;
showImage(nextIndex);
}, 3000); // 每3秒切换
}
}
// 键盘快捷键
function handleKeyboard(e) {
// Ctrl + O 打开文件夹
if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
openFolder();
return;
}
// Ctrl + Shift + O 打开文件
if (e.ctrlKey && e.shiftKey && e.key === 'O') {
e.preventDefault();
openFiles();
return;
}
// 左右箭头切换图片(不支持循环)
if (e.key === 'ArrowRight') {
e.preventDefault();
if (currentIndex < currentImages.length - 1) {
showImage(currentIndex + 1);
}
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
if (currentIndex > 0) {
showImage(currentIndex - 1);
}
}
// Ctrl + + / - 缩放
if (e.ctrlKey && (e.key === '+' || e.key === '=')) {
e.preventDefault();
zoomImage(25);
} else if (e.ctrlKey && e.key === '-') {
e.preventDefault();
zoomImage(-25);
} else if (e.key === '+' || e.key === '=') {
zoomImage(25);
} else if (e.key === '-') {
zoomImage(-25);
}
// Ctrl + 0 适应窗口
if (e.ctrlKey && e.key === '0') {
e.preventDefault();
fitImage();
} else if (e.key === 'f' || e.key === 'F') {
fitImage();
}
// L 向左旋转,R 向右旋转(加入防抖)
if (e.key === 'l' || e.key === 'L') {
e.preventDefault();
if (isRotating) return;
if (isImageLoaded && currentImages.length > 0) {
isRotating = true;
rotation = (rotation - 90 + 360) % 360;
applyZoom();
showStatus(`向左旋转 90° (当前: ${rotation}°)`);
setTimeout(() => { isRotating = false; }, 200);
}
} else if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
if (isRotating) return;
if (isImageLoaded && currentImages.length > 0) {
isRotating = true;
rotation = (rotation + 90) % 360;
applyZoom();
showStatus(`向右旋转 90° (当前: ${rotation}°)`);
setTimeout(() => { isRotating = false; }, 200);
}
}
// F5 幻灯片播放
if (e.key === 'F5') {
e.preventDefault();
toggleSlideshow();
}
// Delete 删除当前图片
if (e.key === 'Delete' && currentIndex >= 0) {
deleteImage(currentIndex);
}
// ESC 停止幻灯片
if (e.key === 'Escape' && slideshowTimer) {
toggleSlideshow();
}
// Home / End 跳转到第一张/最后一张
if (e.key === 'Home' && currentImages.length > 0) {
showImage(0);
} else if (e.key === 'End' && currentImages.length > 0) {
showImage(currentImages.length - 1);
}
// F11 全屏
if (e.key === 'F11') {
e.preventDefault();
// 全屏功能(需要 Electron API)
}
}
// 工具函数
function showStatus(text) {
document.getElementById('status-text').textContent = text;
}
function formatFileSize(bytes) {
if (bytes === undefined || bytes === null || isNaN(bytes)) {
return '未知大小';
}
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 暴露 API 供主进程调用
window.electronAPI = require('electron').ipcRenderer;

关键要点:
- 防抖锁保护(isRotating),避免旋转按钮重复触发
- 事件绑定守卫(eventsInitialized),防止热重载导致重复绑定
- 移除焦点(e.currentTarget.blur()),防止空格/回车键重复触发
- 线性导航逻辑(非循环),第一张/最后一张自动禁用按钮
3.5 第五步:编写样式文件(gthumb.css)
文件:web_engine/src/main/resources/resfile/resources/app/styles/gthumb.css
js
/* gThumb Image Viewer - 主样式文件 */
/* 鸿蒙 ArkWeb 兼容:不使用 CSS 变量 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
background: #1e1e1e;
color: #e0e0e0;
overflow: hidden;
}
/* 主应用容器 */
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
/* 顶部工具栏 */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #2d2d2d;
border-bottom: 1px solid #3a3a3a;
}
.toolbar-left,
.toolbar-right {
display: flex;
gap: 8px;
}
.toolbar-center {
flex: 1;
text-align: center;
}
.path-display {
font-size: 14px;
color: #e0e0e0;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 按钮样式 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: #e0e0e0;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
position: relative;
}
.btn svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.btn:active {
transform: scale(0.95);
}
.btn-active {
background: #0078d4;
color: #ffffff;
}
.btn-active:hover {
background: #106ebe;
}
/* 工具栏分隔线 */
.toolbar-separator {
width: 1px;
height: 24px;
background: #3a3a3a;
margin: 0 4px;
}
/* 缩放显示 */
.zoom-display {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 60px;
height: 36px;
padding: 0 12px;
font-size: 13px;
color: #e0e0e0;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
font-weight: 500;
}
/* 主内容区 */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* 缩略图面板 */
.thumbnail-panel {
width: 360px;
flex-shrink: 0;
background: #252525;
border-right: 1px solid #3a3a3a;
display: flex;
flex-direction: column;
}
.thumbnail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #3a3a3a;
}
.thumbnail-header-left {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.thumbnail-header h2 {
font-size: 16px;
font-weight: 600;
color: #e0e0e0;
margin: 0;
}
.image-count {
font-size: 12px;
color: #888;
}
/* 缩略图网格 */
.thumbnail-grid {
flex: 1;
overflow-y: auto;
padding: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
align-content: start;
}
.thumbnail-item {
position: relative;
aspect-ratio: 1;
border-radius: 6px;
overflow: visible;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
background: #2a2a2a;
}
/* 缩略图删除按钮 */
.thumbnail-delete {
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: #ff4444;
color: #fff;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
z-index: 5;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.thumbnail-delete svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.thumbnail-item:hover .thumbnail-delete {
display: flex;
}
.thumbnail-delete:hover {
background: #ff6666;
transform: scale(1.15);
}
.thumbnail-delete:active {
transform: scale(0.95);
}
.thumbnail-item:hover {
transform: scale(1.03);
border-color: #0078d4;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.thumbnail-active {
border-color: #0078d4;
box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.4);
}
.thumbnail-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 6px 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
color: #fff;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 右侧预览面板 */
.preview-panel {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #1a1a1a;
position: relative;
overflow: hidden;
padding: 20px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #666;
}
.empty-state-icon {
width: 80px;
height: 80px;
fill: #444;
margin-bottom: 16px;
}
.empty-state-text {
font-size: 16px;
margin-bottom: 8px;
}
.empty-state-hint {
font-size: 13px;
color: #555;
}
.image-viewer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
}
.image-viewer img {
max-width: 100%;
max-height: calc(100% - 60px);
object-fit: contain;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: block;
cursor: grab;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
border-radius: 4px;
}
.image-viewer img:active {
cursor: grabbing;
}
.image-info {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.75);
border-radius: 8px;
color: #fff;
font-size: 13px;
backdrop-filter: blur(10px);
max-width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.3s;
white-space: nowrap;
}
.image-info:hover {
background: rgba(0, 0, 0, 0.85);
}
.image-info .separator {
color: #666;
}
#image-position {
color: #0078d4;
font-weight: 600;
}
/* 底部状态栏 */
.status-bar {
height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: #2d2d2d;
border-top: 1px solid #3a3a3a;
font-size: 13px;
color: #b0b0b0;
}
/* 清空按钮样式 */
.btn-clear {
color: #ff6b6b;
}
.btn-clear:hover {
background: rgba(255, 68, 68, 0.15);
color: #ff4444;
}
/* 鸿蒙 ArkWeb 兼容:移除 ::-webkit-scrollbar 样式 */
/* 滚动条样式不使用,鸿蒙不支持 */
/* 图片导航按钮 */
.nav-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
backdrop-filter: blur(10px);
}
.nav-button svg {
width: 24px;
height: 24px;
fill: currentColor;
}
.nav-button:hover {
background: rgba(0, 120, 212, 0.8);
transform: translateY(-50%) scale(1.1);
}
.nav-button:active {
transform: translateY(-50%) scale(0.95);
}
.nav-prev {
left: 20px;
}
.nav-next {
right: 20px;
}

关键要点:
- 暗色主题设计:使用 #1e1e1e、#2d2d2d、#252525 等深色系,专业图片查看器风格
- 双栏布局:左侧缩略图面板固定 360px,右侧预览面板 flex: 1 自适应
- CSS Grid 缩略图网格:auto-fill + minmax(100px, 1fr) 实现响应式网格
- 悬停交互:缩略图悬停放大 1.03 倍 + 蓝色边框,删除按钮 display: none → flex
- Material Design 动画:transition + cubic-bezier 缓动函数,流畅的视觉反馈
- 图片信息浮层:position: absolute + backdrop-filter: blur(10px) 毛玻璃效果
- 导航按钮定位:position: absolute + top: 50% + translateY(-50%) 垂直居中
- 鸿蒙 ArkWeb 兼容:不使用 CSS 变量(var(--xxx)),直接写实际颜色值
- 移除 Webkit 滚动条样式:鸿蒙不支持 ::-webkit-scrollbar,已删除
四、部署到鸿蒙平台
4.1 项目结构说明
开发工作流:
- 直接在 electron-apps/gthumb/ 中修改代码
- 同步到 web_engine/src/main/resources/resfile/resources/app/
- 在 DevEco Studio 中构建并运行
- 真机测试验证
4.2 构建 HAP 包
在 DevEco Studio 中:
- 打开项目根目录 ohos_hap/
- 点击 Build > Build Hap(s)/APP(s)
- 选择 Build Hap(s)
- 等待构建完成

4.3 真机测试
- 连接鸿蒙设备(或启动模拟器)
- 点击 Run > Run 'entry'
- 安装完成后,应用会自动启动



五、常见问题 FAQ
Q1:旋转按钮点击后旋转 180° 而不是 90°?
问题现象:点击向左/向右旋转按钮,图片旋转了 180°
根本原因:点击按钮后按钮获得焦点,再按空格/回车键会再次触发 click 事件
解决方案:
bash
document.getElementById('btn-rotate-left').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.blur(); // ⭐ 移除焦点,防止空格/回车重复触发
if (isRotating) return; // ⭐ 防抖锁
// ...
isRotating = true;
rotation = (rotation - 90 + 360) % 360;
applyZoom();
setTimeout(() => { isRotating = false; }, 200);
});
关键点:
- 三重防御:preventDefault + stopPropagation + blur()
- 防抖锁(isRotating),200ms 内忽略重复触发
- 事件绑定守卫(eventsInitialized),防止热重载重复绑定
Q2:打开文件后图片大小显示 0B?
问题现象:通过"打开文件"按钮加载图片,下方信息栏显示 0 B
根本原因:openFiles() 函数硬编码 size: 0,未调用 IPC 获取真实文件大小
解决方案:
bash
async function openFiles() {
const filePaths = await window.electronAPI.invoke('open-file-dialog');
// ⭐ 并发获取每个文件的真实信息
const infoPromises = filePaths.map(filePath =>
window.electronAPI.invoke('get-image-info', filePath).catch(() => null)
);
const infos = await Promise.all(infoPromises);
const newImages = filePaths.map((filePath, idx) => {
const info = infos[idx];
return {
path: filePath,
name: path.basename(filePath),
type: 'image',
size: (info && typeof info.size === 'number') ? info.size : 0, // ⭐ 真实大小
modified: info ? info.modified : null,
ext: info ? info.ext : path.extname(filePath).toLowerCase()
};
});
}
关键点:
- Promise.all 并发请求,10 张图片只需 1 次往返时间
- 通过 get-image-info IPC 获取真实 stat.size
- 失败容错:单个文件失败不影响其他文件
Q3:适应窗口按钮点击没反应?
问题现象:点击"适应窗口"按钮,图片无任何视觉变化
根本原因:旧版 fitImage() 只是简单设置 currentZoom=100,若已在 100% 则无变化
解决方案:
bash
function fitImage() {
const previewImg = document.getElementById('preview-image');
const container = previewImg.closest('.preview-panel');
const containerWidth = container.clientWidth - 40;
const containerHeight = container.clientHeight - 100;
const naturalWidth = previewImg.naturalWidth;
const naturalHeight = previewImg.naturalHeight;
const ratioW = containerWidth / naturalWidth;
const ratioH = containerHeight / naturalHeight;
const fitRatio = Math.min(ratioW, ratioH, 1);
currentZoom = 100;
applyZoom();
// ⭐ 详细状态反馈
const displayWidth = Math.round(naturalWidth * fitRatio);
const displayHeight = Math.round(naturalHeight * fitRatio);
showStatus(`已适应窗口 · 原始${naturalWidth}×${naturalHeight} → 显示${displayWidth}×${displayHeight} (${Math.round(fitRatio * 100)}%)`);
}
关键点:
- 智能计算容器与图片比例,取最小值适配
- 即使图片本身已适配,也显示详细尺寸提示
- 多重边界保护(naturalWidth、容器存在性)
Q4:在第一张图片时点击"上一张"会循环到最后一张?
问题现象:第一张图片点击"上一张"按钮,跳转到最后一张
根本原因:使用取模运算实现循环导航,不符合用户预期
解决方案:
bash
// ⭐ 线性导航(非循环)
document.getElementById('btn-prev-image').addEventListener('click', () => {
if (currentIndex > 0) {
showImage(currentIndex - 1);
}
});
// ⭐ 自动隐藏对应按钮
function updateNavButtons() {
const btnPrev = document.getElementById('btn-prev-image');
const btnNext = document.getElementById('btn-next-image');
if (currentIndex <= 0) {
btnPrev.style.opacity = '0.3';
btnPrev.style.pointerEvents = 'none';
}
if (currentIndex >= currentImages.length - 1) {
btnNext.style.opacity = '0.3';
btnNext.style.pointerEvents = 'none';
}
}
关键点:
- 线性边界检查(> 0 和 < length - 1)
- 第一张隐藏上一张,最后一张隐藏下一张
- opacity + pointerEvents 实现禁用视觉效果
Q5:鸿蒙平台 CSS 样式不生效?
问题现象:部分 CSS 样式在鸿蒙设备上未显示
根本原因:鸿蒙 ArkWeb 不支持 CSS 自定义属性(变量)
解决方案:
bash
/* ❌ 错误:使用 CSS 变量 */
.preview-panel {
background: var(--bg-dark);
}
/* ✅ 正确:使用实际值 */
.preview-panel {
background: #1a1a1a; /* 暗色背景 */
}
/* ❌ 错误:使用 CSS 变量 */
.btn:hover {
background: var(--hover-color);
}
/* ✅ 正确:使用实际值 */
.btn:hover {
background: #404040; /* 悬停高亮 */
}
关键点:
- 将所有 CSS 变量替换为实际值
- ArkWeb 不支持 var(--xxx) 自定义属性
- 颜色值直接使用十六进制或 rgba
- 其他 CSS 特性(flex、transition、transform)均支持
Q6:图片旋转图标方向不正确?
问题现象:向左/向右旋转图标视觉上无法区分方向
根本原因:之前两个图标使用相同的箭头路径 M12 5V1L7 6l5 5V7,仅圆弧方向不同
解决方案:
bash
<!-- ✅ Material Design rotate_right - 顺时针旋转(箭头指向右上方) -->
<symbol id="icon-rotate" viewBox="0 0 24 24">
<path d="M15.55 5.55L11 1v3.07C7.06 4.56 4 7.92 4 12s3.05 7.44 7 7.93v-2.02c-2.84-.48-5-2.94-5-5.91s2.16-5.43 5-5.91V8l4.55-2.45zM19.93 11c-.17-1.39-.72-2.73-1.62-3.89l-1.42 1.42c.54.75.88 1.6 1.02 2.47h2.02zM13 17.9v2.02c1.39-.17 2.74-.71 3.9-1.61l-1.44-1.44c-.75.54-1.59.89-2.46 1.03zm3.89-2.42l1.42 1.41c.9-1.16 1.45-2.5 1.62-3.89h-2.02c-.14.87-.48 1.72-1.02 2.48z"/>
</symbol>
<!-- ✅ Material Design rotate_left - 逆时针旋转(箭头指向左上方) -->
<symbol id="icon-rotate-left" viewBox="0 0 24 24">
<path d="M7.11 8.53L5.7 7.11C4.8 8.27 4.24 9.61 4.07 11h2.02c.14-.87.49-1.72 1.02-2.47zM6.09 13H4.07c.17 1.39.72 2.73 1.62 3.89l1.41-1.42c-.52-.75-.87-1.59-1.01-2.47zm1.01 5.32c1.16.9 2.51 1.44 3.9 1.61V17.9c-.87-.15-1.71-.49-2.46-1.03L7.1 18.32zM13 4.07V1L8.45 5.55 13 10V6.09c2.84.48 5 2.94 5 5.91s-2.16 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93s-3.05-7.44-7-7.93z"/>
</symbol>
关键点:
- 采用 Material Design 官方标准图标
- rotate_right 箭头指向右上方,rotate_left 箭头指向左上方
- 视觉上清晰区分旋转方向
Q7:缩略图悬停删除按钮不显示?
问题现象:鼠标移到缩略图上,没有出现删除图标
根本原因:需要在 mouseenter 事件动态创建按钮,mouseleave 移除
解决方案:
bash
thumb.onmouseenter = () => {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'thumbnail-delete';
deleteBtn.innerHTML = '✕';
deleteBtn.onclick = (e) => {
e.stopPropagation(); // ⭐ 阻止触发缩略图点击
deleteImage(index);
};
thumb.appendChild(deleteBtn);
};
thumb.onmouseleave = () => {
const deleteBtn = thumb.querySelector('.thumbnail-delete');
if (deleteBtn) deleteBtn.remove();
};
关键点:
- 动态创建/删除按钮,避免 DOM 冗余
- stopPropagation 阻止事件冒泡到缩略图
- CSS 定位:position: absolute + top/right 偏移
Q8:鸿蒙平台构建失败或文件未加载?
问题现象:hvigor 构建时报错,或应用启动后白屏
根本原因:文件未正确放置在 resfile 目录或同步不完整
解决方案:
- 确认文件结构正确:
bash
web_engine/src/main/resources/resfile/resources/app/
├── main.js
├── renderer.js
├── index.html
└── styles/
└── gthumb.css
- 从 electron-apps 同步到 web_engine:
bash
# 在 PowerShell 中执行
Copy-Item -Path "electron-apps\gthumb\main.js" -Destination "web_engine\src\main\resources\resfile\resources\app\main.js" -Force
Copy-Item -Path "electron-apps\gthumb\renderer.js" -Destination "web_engine\src\main\resources\resfile\resources\app\renderer.js" -Force
Copy-Item -Path "electron-apps\gthumb\index.html" -Destination "web_engine\src\main\resources\resfile\resources\app\index.html" -Force
Copy-Item -Path "electron-apps\gthumb\styles\gthumb.css" -Destination "web_engine\src\main\resources\resfile\resources\app\styles\gthumb.css" -Force
- 验证文件加载:
bash
// 在 Index.ets 中添加日志
Web({ src: $rawfile('resources/app/index.html') })
.onPageBegin((event) => {
console.info('WebView 开始加载:', event.url);
})
.onPageEnd((event) => {
console.info('WebView 加载完成:', event.url);
})
.onErrorReceive((event) => {
console.error('WebView 加载失败:', JSON.stringify(event));
})
注意事项:
- resfile 目录下的文件使用 $rawfile() 加载
- 确保所有文件路径正确,无拼写错误
- 真机测试时检查 DevEco Studio 控制台日志
- 每次修改后必须重新同步并构建