欢迎光临
我们一直在努力

鸿蒙平台 gThumb 图片查看器适配实战:从 Linux GTK 到 Electron 鸿蒙壳工程

项目简介

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)

gThumb (C/GTK Linux Desktop)
├── UI 渲染:GTK+ 3 Widget
├── 图片加载:GDK-Pixbuf
├── 缩略图缓存:~/.cache/gthumb
└── 文件系统:GIO/GFile

1.2 目标架构(鸿蒙 Electron)

鸿蒙壳工程 (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 项目结构

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

// 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

<!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

{
"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

// 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

/* 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 事件

    解决方案:

    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 获取真实文件大小

    解决方案:

    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% 则无变化

    解决方案:

    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:在第一张图片时点击"上一张"会循环到最后一张?

    问题现象:第一张图片点击"上一张"按钮,跳转到最后一张

    根本原因:使用取模运算实现循环导航,不符合用户预期

    解决方案:

    // ⭐ 线性导航(非循环)
    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 自定义属性(变量)

    解决方案:

    /* ❌ 错误:使用 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,仅圆弧方向不同

    解决方案:

    <!— ✅ 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 移除

    解决方案:

    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 目录或同步不完整

    解决方案:

  • 确认文件结构正确:
  • web_engine/src/main/resources/resfile/resources/app/
    ├── main.js
    ├── renderer.js
    ├── index.html
    └── styles/
    └── gthumb.css

  • 从 electron-apps 同步到 web_engine:
  • # 在 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

  • 验证文件加载:
  • // 在 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 控制台日志
    • 每次修改后必须重新同步并构建
    赞(0)
    未经允许不得转载:171主机测评 » 鸿蒙平台 gThumb 图片查看器适配实战:从 Linux GTK 到 Electron 鸿蒙壳工程
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址