前言

欢迎加入鸿蒙PC开发者社区,共同打造开发者工具生态:鸿蒙PC开发者社区 :https://harmonypc.csdn.net/

项目开源地址:https://AtomGit.com/lqjmac/ele-yingxiangxunjian

很多图片管理工具只解决“看图”和“分类”,但在真实桌面工作里,图片经常不是单纯被欣赏,而是要被检查、标记、复核和交付。

我把这个案例命名为 影像巡检台,重点不是做一个普通图库,而是把图片相关的判断过程沉淀下来。

它适合处理这些场景:

  • 采样图片需要逐条复核
  • 设计素材需要记录异常和来源
  • 影像批次需要导出检查结论
  • 本地桌面工具需要稳定运行、可复制、可导出

一个桌面图片工具要成立,不能只看缩略图够不够漂亮,还要看它能不能留下可追溯的判断记录。

本文会围绕 Vue3、Electron、鸿蒙 PC 运行环境、剪贴板、文件导出和构建检查展开。

相关技术栈可以参考 Electron 官方文档Vue 官方文档Vite 官方文档TypeScript 文档

一、先确定影像巡检台要解决什么

1.1 普通图库和巡检台的区别

图库关注的是浏览体验。

巡检台关注的是判断链路。

两者的差异可以先用一张表压住:

对比项 普通图库 影像巡检台
核心对象 图片文件 影像检查记录
主要动作 浏览、放大、收藏 标记、复核、导出
数据重点 文件路径、缩略图 批次、异常、结论
输出结果 看过即可 可交付的巡检摘要

这个差异会影响字段设计、页面结构和导出内容。

如果一开始没有把对象想清楚,后面很容易写成“左侧列表 + 右侧详情”的通用模板。

1.2 初始版本先做闭环

影像巡检台的初始版本不急着接入真实文件扫描。

原因很简单:真实文件扫描会引入路径权限、缩略图缓存、批量导入和性能优化。

如果主流程还没确定,就先做这些底层能力,容易把项目带散。

我先把闭环定成下面五步:

  1. 新建一条影像巡检记录
  2. 填写来源、异常类型和复核摘要
  3. 在列表中按状态和关键词找回
  4. 一键复制当前摘要
  5. 导出 Markdown 巡检记录

这五步跑通以后,再接真实图片文件会更稳。

初始版本不是功能少,而是先把最核心的业务路径打穿。

二、先看文件分工怎样服务巡检流程

2.1 文件职责表

文件 主要职责 影像巡检台里的关注点
Home.vue 页面总装 巡检列表、编辑区、工具栏组合
useInspectItems.ts 状态管理 本地保存、筛选、当前记录
useNativeBridge.ts 原生桥接 剪贴板、导出、通知
electron/main.js 主进程入口 窗口创建和本地页面加载
electron/preload.js 预加载桥 暴露安全 API
App.vue 应用外壳 保证页面滚动和窗口填充

这种分工的好处是页面不直接碰底层能力。

页面只表达“我要复制”“我要导出”“我要通知”,具体是在浏览器预览还是鸿蒙运行时完成,由桥接层处理。

三、页面结构图

3.1 功能结构图

在这里插入图片描述

这张图对应影像巡检台的核心结构:左侧负责检索和状态入口,中间负责巡检记录,右侧负责复核信息和导出摘要。

3.2 三栏不是装饰

影像巡检台采用 状态入口 + 巡检列表 + 复核编辑区 的结构。

它不是为了把页面做复杂,而是为了让用户从“找问题”自然走到“写结论”。

区域 用户动作 设计重点
左侧入口 搜索、切换状态、查看统计 入口要短,不放长说明
中间列表 扫描异常记录 卡片要突出状态和批次
右侧编辑 写复核结论 字段要有足够上下文

这种布局在 PC 窗口里比较自然。

宽屏时三栏同时展示,窄一点时保持内容区域可滚动。

四、字段模型要服务巡检判断

4.1 核心字段设计

影像巡检台不能只保存 titlecontent

它至少要知道图片来自哪里、现在是什么状态、为什么需要复查。

字段 含义 页面位置
id 巡检记录唯一标识 状态层
title 影像记录标题 列表和编辑区
batch 采集批次或来源 编辑区
issueType 异常类型 列表和编辑区
state 当前巡检状态 筛选和状态徽标
summary 巡检摘要 复制和导出
highlights 关键画面特征 右侧信息区
nextReview 复核时间 复核提示

字段不是为了显得完整,而是让每条影像记录能独立说明问题。

4.2 TypeScript 类型

export type InspectState = 'pending' | 'confirmed' | 'reviewing';

export type InspectCategory =
  | 'sample'
  | 'abnormal'
  | 'workflow'
  | 'standard';

export interface InspectItem {
  id: string;
  title: string;
  batch: string;
  issueType: string;
  category: InspectCategory;
  state: InspectState;
  keywords: string;
  summary: string;
  highlights: string;
  content: string;
  nextReview: string;
  pinned: boolean;
  archived: boolean;
  createdAt: number;
  updatedAt: number;
}

这里把 statecategory 拆开,是为了避免状态和分类互相污染。

分类描述它是什么资料,状态描述它处理到哪一步。

五、状态命名要让用户看懂

5.1 显示文案不要直接暴露枚举

代码里可以使用英文枚举,但界面上要用业务语言。

const stateLabelMap: Record<InspectState, string> = {
  pending: '待巡检',
  confirmed: '已确认',
  reviewing: '需复核',
};

const categoryLabelMap: Record<InspectCategory, string> = {
  sample: '待检影像',
  abnormal: '异常线索',
  workflow: '复核流程',
  standard: '采样规范',
};

这段映射会出现在列表、编辑区、导出内容和筛选入口中。

统一它们可以避免同一状态在不同位置出现不同叫法。

5.2 状态颜色不要过多

状态色只保留三种即可:

  • 待巡检:提示还有任务未处理
  • 已确认:说明记录可以流转
  • 需复核:提醒用户优先查看

过多颜色会让影像巡检台像监控大屏,但这个工具本质上还是个人桌面工作台。

六、种子数据要像真实巡检记录

6.1 为什么不能写测试数据

如果默认数据写成“图片1”“图片2”,页面看起来会很空。

真实一点的种子数据能同时测试列表、摘要、关键词、状态和导出。

export const seedInspectItems: InspectItem[] = [
  {
    id: 'inspect-glass-reflection',
    title: '玻璃反光区域复核',
    batch: 'A-2026-05-23',
    issueType: '反光干扰',
    category: 'abnormal',
    state: 'reviewing',
    keywords: '反光,边缘,遮挡',
    summary: '样本右上角出现连续反光,需要复核是否影响边缘识别。',
    highlights: '高亮区域集中在右上角,建议补拍同角度样本。',
    content: '采样环境中存在强光源,右上角反光覆盖目标边缘。',
    nextReview: '2026-05-25',
    pinned: true,
    archived: false,
    createdAt: Date.now() - 3600_000,
    updatedAt: Date.now(),
  },
];

这条数据能让界面一打开就知道工具在做什么。

6.2 默认数据的写作标准

我给默认记录定了三条标准:

  1. 标题要能看出问题
  2. 摘要要能被直接复制
  3. 高亮要能提示下一步动作

这样默认数据既能服务演示,也能服务测试。

七、本地保存不要和页面混在一起

7.1 读取逻辑

本地工具最基本的要求是重启后数据还在。

这里可以用 localStorage 做初始版本的数据持久化。

const STORAGE_KEY = 'yingxiang-xunjian-items:v1';

function loadItems(): InspectItem[] {
  if (typeof window === 'undefined') {
    return seedInspectItems;
  }

  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) return seedInspectItems;
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : seedInspectItems;
  } catch {
    return seedInspectItems;
  }
}

这段代码的重点是兜底。

本地缓存一旦被手动改坏,应用也不应该直接白屏。

7.2 保存逻辑

const items = ref<InspectItem[]>(loadItems());

function persistItems() {
  if (typeof window === 'undefined') return;
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items.value));
}

保存动作可以配合防抖。

编辑器输入频繁,不能每敲一个字都同步写入。

桌面端应用的“已保存”反馈很重要,它会直接影响用户敢不敢把真实资料放进去。

八、筛选排序要突出待处理影像

8.1 搜索字段

影像巡检台的搜索不只看标题。

批次、异常类型、关键词、摘要都应该参与搜索。

const searchText = ref('');
const activeState = ref<'all' | InspectState>('all');

const filteredItems = computed(() => {
  const keyword = searchText.value.trim().toLowerCase();

  return items.value
    .filter(item => {
      if (activeState.value !== 'all' && item.state !== activeState.value) {
        return false;
      }

      if (!keyword) return true;

      return [
        item.title,
        item.batch,
        item.issueType,
        item.keywords,
        item.summary,
        item.highlights,
      ].join(' ').toLowerCase().includes(keyword);
    })
    .sort(sortInspectItems);
});

这个筛选逻辑能覆盖真实查找方式。

用户可能记得批次,也可能只记得“反光”。

8.2 排序规则

function sortInspectItems(a: InspectItem, b: InspectItem) {
  if (a.archived !== b.archived) return a.archived ? 1 : -1;
  if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
  if (a.state !== b.state) {
    const order: Record<InspectState, number> = {
      reviewing: 0,
      pending: 1,
      confirmed: 2,
    };
    return order[a.state] - order[b.state];
  }
  return b.updatedAt - a.updatedAt;
}

排序不是技术细节,而是产品判断。

需要复核的记录应该靠前,已归档的记录应该沉下去。

九、Vue 页面负责组织工作台

9.1 Home.vue 的骨架

Home.vue 不应该承担所有逻辑。

它更适合做页面编排,把状态层、工具栏、列表和编辑器连起来。

<template>
  <section class="inspect-workbench">
    <aside class="inspect-rail">
      <InspectSidebar
        :items="filteredItems"
        :summary="summary"
        v-model:search-text="searchText"
        @create="createItem"
        @select="selectItem"
      />
    </aside>

    <main class="inspect-main">
      <InspectToolbar
        :has-item="Boolean(currentItem)"
        @copy="handleCopy"
        @export="handleExport"
      />

      <InspectEditor
        v-if="currentItem"
        :item="currentItem"
        @update="updateCurrentItem"
      />
    </main>
  </section>
</template>

这段模板的目标是清楚。

数据和动作都从状态层来,页面负责摆放。

9.2 反馈信息

复制、导出、保存都要有反馈。

桌面应用如果没有反馈,用户很难判断动作是否成功。

const feedback = ref('');

function showFeedback(message: string) {
  feedback.value = message;
  window.clearTimeout(feedbackTimer);
  feedbackTimer = window.setTimeout(() => {
    feedback.value = '';
  }, 2200);
}

轻量反馈比弹窗更适合高频操作。

十、编辑区要承接复核结论

10.1 编辑字段布局

编辑区不能只放一个 textarea。

巡检记录需要结构化字段。

<template>
  <article class="inspect-editor">
    <input
      class="title-input"
      :value="item.title"
      placeholder="输入影像记录标题"
      @input="emitField('title', $event)"
    />

    <div class="meta-grid">
      <label>
        <span>采集批次</span>
        <input :value="item.batch" @input="emitField('batch', $event)" />
      </label>

      <label>
        <span>异常类型</span>
        <input :value="item.issueType" @input="emitField('issueType', $event)" />
      </label>
    </div>

    <textarea
      class="content-input"
      :value="item.content"
      placeholder="记录复核依据、画面问题和下一步处理动作"
      @input="emitField('content', $event)"
    />
  </article>
</template>

标题、批次、异常类型和正文分开后,导出时也更好组织。

10.2 输入事件处理

function emitField(field: keyof InspectItem, event: Event) {
  const target = event.target as HTMLInputElement | HTMLTextAreaElement;
  emit('update', {
    id: props.item.id,
    patch: {
      [field]: target.value,
    },
  });
}

这里不要在子组件里直接改状态。

单向数据流能让保存和撤销都更容易扩展。

十一、复制动作要复制有价值的内容

11.1 摘要优先

影像巡检台的复制按钮不应该默认复制标题。

用户点击复制,通常是要把结论发给别人。

async function handleCopy() {
  if (!currentItem.value) return;

  const text =
    currentItem.value.summary ||
    currentItem.value.highlights ||
    currentItem.value.title;

  const ok = await copyText(text);

  if (ok) {
    showFeedback('巡检摘要已复制');
    await notify('影像巡检台', '当前巡检摘要已经复制到剪贴板');
  }
}

这个顺序很实用:

  1. 有摘要就复制摘要
  2. 没摘要就复制关键高亮
  3. 再没有才复制标题

11.2 剪贴板文档

剪贴板能力可以参考 MDN Clipboard API

在 Electron 环境中,也可以参考 Electron clipboard

鸿蒙 Electron 运行时可以通过预加载脚本统一封装,避免页面直接判断环境。

十二、导出 Markdown 要像巡检单

12.1 导出内容结构

导出的 Markdown 不是备份,而是交付记录。

function buildInspectMarkdown(item: InspectItem) {
  return [
    `# ${item.title || '未命名影像巡检记录'}`,
    '',
    `- 分类:${categoryLabelMap[item.category]}`,
    `- 状态:${stateLabelMap[item.state]}`,
    `- 批次:${item.batch || '未设置'}`,
    `- 异常类型:${item.issueType || '未设置'}`,
    `- 关键词:${item.keywords || '未设置'}`,
    `- 下次复核:${item.nextReview || '未设置'}`,
    '',
    '## 巡检摘要',
    '',
    item.summary || '暂无摘要',
    '',
    '## 关键高亮',
    '',
    item.highlights || '暂无高亮',
    '',
    '## 复核记录',
    '',
    item.content || '暂无正文',
  ].join('\n');
}

这样导出的内容可以直接贴进项目文档或验收记录。

12.2 文件名处理

function safeFileName(value: string) {
  return (value.trim() || '影像巡检记录')
    .replace(/[\\/:*?"<>|]/g, '-')
    .slice(0, 80);
}

文件名一定要做非法字符处理。

否则在不同系统之间保存时会出现奇怪问题。

十三、原生桥接层负责环境差异

13.1 统一 API

页面层不要散落一堆 window.electronAPI 判断。

桥接层统一输出方法:

export function useNativeBridge() {
  const isNativeRuntime = computed(() => Boolean(window.ohosElectron));

  async function copyText(text: string) {
    if (!text.trim()) return false;
    if (window.ohosElectron?.clipboard) {
      return window.ohosElectron.clipboard.writeText(text);
    }
    await navigator.clipboard.writeText(text);
    return true;
  }

  return {
    isNativeRuntime,
    copyText,
    exportMarkdown,
    notify,
  };
}

这层写稳以后,其他桌面工具也能复用。

13.2 预加载脚本暴露能力

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('ohosElectron', {
  clipboard: {
    writeText: text => ipcRenderer.invoke('clipboard:write-text', text),
  },
  file: {
    saveMarkdown: payload => ipcRenderer.invoke('file:save-markdown', payload),
  },
  notification: {
    show: payload => ipcRenderer.invoke('notification:show', payload),
  },
});

预加载层只暴露必要能力。

这比把 Node 能力直接丢给页面安全得多。

十四、主进程加载要避免白屏

14.1 本地 dist 路径

鸿蒙 Electron 打包后,页面应加载本地 dist/index.html

const path = require('path');
const { app, BrowserWindow } = require('electron');

function createWindow() {
  const win = new BrowserWindow({
    width: 1225,
    height: 850,
    title: '影像巡检台',
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
    },
  });

  const indexPath = path.join(__dirname, '..', 'dist', 'index.html');
  win.loadFile(indexPath, {
    query: {
      route: '/',
      windowLabel: 'main',
    },
  });
}

app.whenReady().then(createWindow);

如果日志里出现 ERR_FILE_NOT_FOUND,优先检查 HAP 包内是否真的存在 resources/app/dist/index.html

14.2 构建检查命令

npm run build
test -f dist/index.html
find dist -maxdepth 2 -type f | sort

这些命令很基础,但能快速排除资源没有进入包的问题。

十五、滚动和窗口控制要分清责任

15.1 页面滚动

PC 应用里,窗口高度经常变化。

如果外层没有设置好 min-height: 0,内部滚动很容易失效。

html,
body,
#app {
  width: 100%;
  height: 100%;
  margin: 0;
}

.app-container {
  width: 100%;
  height: 100vh;
  min-height: 0;
  display: grid;
  grid-template-rows: minmax(0, 1fr);
  overflow: hidden;
}

.app-content {
  min-height: 0;
  overflow: auto;
}

这个写法让窗口保持满屏,内容区自己滚动。

15.2 窗口按钮

最小化、最大化、关闭应尽量交给原生窗口栏。

如果业务页面自己做窗口按钮,就要同时处理拖拽区域、双击最大化、焦点状态和异常退出。

对于这个工具,原生窗口栏更稳定。

十六、样式要突出可读性

16.1 主视觉

影像巡检台适合使用偏冷静的深蓝灰和浅色内容区。

它不是影集,也不是设计展板。

它需要的是稳定、耐看和高信息密度。

.inspect-workbench {
  min-height: 100%;
  display: grid;
  grid-template-columns: 280px minmax(0, 1fr);
  background: #eef3f8;
  color: #223047;
}

.inspect-card {
  border: 1px solid #d6dee8;
  background: #ffffff;
  border-radius: 8px;
  padding: 14px;
}

.inspect-card.reviewing {
  border-color: #d66a38;
  box-shadow: inset 4px 0 0 #d66a38;
}

状态色只用在关键位置。

16.2 卡片摘要

function excerpt(value: string) {
  return value.trim().replace(/\s+/g, ' ').slice(0, 96) || '暂无巡检摘要';
}

摘要截断看起来简单,但列表能否扫读,很大程度取决于它。

十七、发布前检查清单

17.1 功能检查

发布前我会按下面顺序过一遍:

  1. 打开应用后标题显示为影像巡检台
  2. 新建记录后能立即进入编辑区
  3. 搜索批次、异常类型、关键词都能命中
  4. 复制摘要能写入剪贴板
  5. 导出 Markdown 能保留巡检元信息
  6. 页面内容超过窗口高度时可以滚动
  7. 关闭窗口不会误删本地数据

这些检查比单纯看页面截图可靠。

17.2 构建检查

检查项 期望结果 备注
dist/index.html 存在 避免白屏
assets 文件 存在 CSS 和 JS 进入包
标题文案 影像巡检台 不串到其他工具
剪贴板 可复制 桥接层可用
页面滚动 可滚动 长内容不截断

如果 HAP 日志出现资源加载错误,应先看构建产物,再看运行时路径。

十八、可以继续扩展的方向

18.1 真实图片能力

影像巡检台后续可以接入真实图片能力:

  • 文件选择器导入图片
  • 缩略图缓存
  • 批量批次管理
  • 图片元信息读取
  • 标注区域截图

这些能力可以参考 MDN File APIElectron dialog

18.2 工程化增强

工程层还可以补这些能力:

  1. 用 IndexedDB 替代 localStorage
  2. 给导出记录加入模板
  3. 给状态变更加入历史记录
  4. 加入键盘快捷键
  5. 增加批量归档

IndexedDB 可以参考 MDN IndexedDB

总结

影像巡检台的重点不是把图片显示出来,而是把图片背后的判断过程做成可复核的桌面工作流。

通过字段建模、状态筛选、剪贴板复制、Markdown 导出和鸿蒙 Electron 本地加载,这个工具可以完成从记录到交付的一条闭环。

如果后续要继续扩展,我会先接真实图片导入和缩略图缓存,再考虑批量处理和标注能力。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐