鸿蒙平台 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)

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 项目结构说明

开发工作流

  1. 直接在 electron-apps/gthumb/ 中修改代码
  2. 同步到 web_engine/src/main/resources/resfile/resources/app/
  3. 在 DevEco Studio 中构建并运行
  4. 真机测试验证

4.2 构建 HAP 包

在 DevEco Studio 中:

  1. 打开项目根目录 ohos_hap/
  2. 点击 Build > Build Hap(s)/APP(s)
  3. 选择 Build Hap(s)
  4. 等待构建完成

4.3 真机测试

  1. 连接鸿蒙设备(或启动模拟器)
  2. 点击 Run > Run 'entry'
  3. 安装完成后,应用会自动启动

五、常见问题 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 目录或同步不完整

解决方案:

  1. 确认文件结构正确:
bash 复制代码
web_engine/src/main/resources/resfile/resources/app/
├── main.js
├── renderer.js
├── index.html
└── styles/
    └── gthumb.css
  1. 从 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
  1. 验证文件加载:
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 控制台日志
  • 每次修改后必须重新同步并构建
相关推荐
wuminyu2 小时前
Java世界中StringTable源码剖析
java·linux·c语言·jvm·c++
金启攻2 小时前
鸿蒙原生应用开发实战(四):复杂页面与交互体验——鱼种百科、天气详情与钓点详情
harmonyos
lqj_本人2 小时前
鸿蒙pc:Hoppscotch-hoppscotch-ohos适配全记录
华为·harmonyos
xcLeigh2 小时前
鸿蒙PC平台 imv 图片查看器适配实战:极简主义设计的 Electron 迁移
华为·electron·harmonyos·鸿蒙·imv·图片操作·web_engine
不羁的木木2 小时前
《HarmonyOS 6.1 新能力实战之智感握姿》第四篇:进阶应用——横屏游戏手柄模式
游戏·华为·harmonyos
风华圆舞3 小时前
在 Flutter 鸿蒙项目里接入语音识别的完整思路
flutter·语音识别·harmonyos
Swift社区3 小时前
鸿蒙游戏Runtime解析:Store如何驱动整个游戏世界?
游戏·华为·harmonyos
YM52e4 小时前
手写模型集合书籍鸿蒙PC ArkTS 对象字面量类型问题约束深度解析
学习·华为·harmonyos·鸿蒙