v1.5.1: 文传易外链+缩略图改截预览webview+工程整体加密+缩略图压缩

- 配置按钮组新增「文传易」外链(关于之前,系统浏览器打开 http://wenchuanyi.bbitcn.net/)
- 缩略图改为对编辑区预览 webview 直接截图(所见即所得),非当前预览页回退按 URL 截图
- 工程 JSON 整体加密(AES-256-GCM,enc:v1: 覆盖整个 JSON)
- 缩略图压缩 ≤500KB(PNG→JPEG)
- 版本号升至 1.5.1(同步 settings/utils/store/ProjectInfo/AboutPanel 默认值)
- 更新开发进度存档与 MEMORY 至 v1.5.1
This commit is contained in:
2026-08-23 18:40:54 +08:00
parent e0d96051f1
commit ef6075a810
19 changed files with 255 additions and 60 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smart-agri-center",
"version": "0.2.0",
"version": "1.5.1",
"description": "智慧农业大数据可视化控制中心(Electron + React + TypeScript",
"main": "./out/main/index.js",
"author": "FanHongCai",
+51 -2
View File
@@ -1,4 +1,5 @@
import { app, dialog, ipcMain, shell, BrowserWindow } from 'electron'
import { app, clipboard, dialog, ipcMain, nativeImage, shell, BrowserWindow } from 'electron'
import type { NativeImage } from 'electron'
import { exec } from 'child_process'
import { dirname, join } from 'path'
import {
@@ -35,7 +36,45 @@ function beepSystem(): void {
exec('powershell -NoProfile -NonInteractive -Command "[console]::beep(880,400)"', () => undefined)
}
/** 截图压缩为 JPEG dataURL,保证体积不超过 maxBytes(默认 500KB):先降质量,再逐步缩小尺寸 */
function compressSnapshot(img: NativeImage, maxBytes = 500 * 1024): string {
let cur = img
const fit = (side: number): void => {
const { width, height } = cur.getSize()
const maxSide = Math.max(width, height)
if (maxSide > side) {
const k = side / maxSide
cur = cur.resize({ width: Math.max(1, Math.round(width * k)), height: Math.max(1, Math.round(height * k)) })
}
}
const toData = (q: number): { buf: Buffer; url: string } => {
const buf = cur.toJPEG(q)
return { buf, url: 'data:image/jpeg;base64,' + buf.toString('base64') }
}
fit(1600)
for (const q of [80, 65, 50, 35]) {
const { buf, url } = toData(q)
if (buf.length <= maxBytes) return url
}
for (const side of [1280, 960, 640, 480]) {
fit(side)
for (const q of [60, 45, 30]) {
const { buf, url } = toData(q)
if (buf.length <= maxBytes) return url
}
}
return toData(25).url
}
export function registerIpc(): void {
// ===== 剪贴板写入(主进程 clipboard 保证可靠,渲染层 navigator.clipboard 易受权限/焦点影响) =====
ipcMain.handle('clipboard:write', (_e, text: string): void => {
clipboard.writeText(String(text ?? ''))
})
// ===== 系统浏览器打开外部链接 =====
ipcMain.handle('shell:openExternal', (_e, url: string): Promise<void> => shell.openExternal(url))
// ===== 窗口控制 =====
ipcMain.on('win:minimize', () => BrowserWindow.getFocusedWindow()?.minimize())
ipcMain.on('win:toggle-maximize', () => {
@@ -160,6 +199,16 @@ export function registerIpc(): void {
ipcMain.handle('app:consumeLoad', () => consumePendingLoad())
// ===== 页面截图(预览图)=====
// 预览 webview 截图 → PNG Buffer → 主进程压缩为 ≤500KB 的 JPEG dataURL
ipcMain.handle('page:compress', (_e, png: Uint8Array) => {
try {
const img = nativeImage.createFromBuffer(Buffer.from(png))
if (img.isEmpty()) return null
return compressSnapshot(img)
} catch {
return null
}
})
ipcMain.handle('page:snapshot', async (_e, url: string) => {
if (!url || url === 'about:blank') return null
const win = new BrowserWindow({
@@ -181,7 +230,7 @@ export function registerIpc(): void {
await new Promise((r) => setTimeout(r, 1600))
const img = await win.webContents.capturePage()
if (img.isEmpty()) return null
return img.toDataURL()
return compressSnapshot(img)
} catch {
return null
} finally {
+18 -13
View File
@@ -48,12 +48,27 @@ export function pagesToSync(json: ProjectJson): SyncPage[] {
}
/**
* 读取工程文件并解密敏感字段
* 读取工程文件。
* - 整体加密文件(内容以 enc:v1: 开头):用密码整体解密后解析
* - 旧格式(明文 / 仅字段加密):逐字段解密
* 密码错误时返回 error='密码错误'
*/
export async function readProjectFile(path: string, password: string): Promise<ReadResult> {
try {
const raw = await fs.readFile(path, 'utf8')
// 整体加密:encryptText 输出的前缀是 enc:v1:
if (raw.startsWith('enc:v1:')) {
const dec = decryptText(raw, password)
if (dec === null) return { json: null, path, error: '密码错误' }
let json: ProjectJson
try {
json = JSON.parse(dec) as ProjectJson
} catch {
return { json: null, path, error: 'JSON 文件无法解析' }
}
return { json, path }
}
// 旧格式:明文 JSON,敏感字段可能为 enc:v1: 前缀密文
const json = JSON.parse(raw) as ProjectJson
if (Array.isArray(json.pages)) {
for (const p of json.pages) {
@@ -75,24 +90,14 @@ export async function readProjectFile(path: string, password: string): Promise<R
}
}
/** 保存工程文件;设置了密码时对 URL / 参数加密存储 */
/** 保存工程文件;设置了密码时对整个 JSON 内容加密存储 */
export async function writeProjectFile(
path: string,
data: string,
password: string
): Promise<{ ok: boolean; error?: string }> {
try {
let out = data
if (password) {
const json = JSON.parse(data) as ProjectJson
if (Array.isArray(json.pages)) {
for (const p of json.pages) {
if (typeof p.PageUrl === 'string') p.PageUrl = encryptText(p.PageUrl, password)
if (typeof p.PageParams === 'string') p.PageParams = encryptText(p.PageParams, password)
}
}
out = JSON.stringify(json, null, 2)
}
const out = password ? encryptText(data, password) : data
await fs.writeFile(path, out, 'utf8')
return { ok: true }
} catch (e) {
+1 -1
View File
@@ -29,7 +29,7 @@ export interface AppSettings {
const DEFAULTS: AppSettings = {
projectName: '智慧农业大数据可视化控制中心',
projectVersion: '0.2.0',
projectVersion: '1.5.1',
jsonPassword: '',
keyThreshold: 350,
keyLockKey: 'F9',
+7
View File
@@ -68,6 +68,12 @@ export interface AppInfo {
}
export interface SmartAgriApi {
clipboard: {
writeText(text: string): Promise<void>
}
shell: {
openExternal(url: string): Promise<void>
}
win: {
minimize(): void
toggleMaximize(): void
@@ -119,6 +125,7 @@ export interface SmartAgriApi {
}
page: {
snapshot(url: string): Promise<string | null>
compress(png: Uint8Array): Promise<string | null>
fetchMeta(url: string): Promise<{ title: string; desc: string } | null>
}
syncProject(pages: unknown[]): void
+7
View File
@@ -8,6 +8,12 @@ function on<T>(channel: string, cb: (payload: T) => void): () => void {
}
const api = {
clipboard: {
writeText: (text: string): Promise<void> => ipcRenderer.invoke('clipboard:write', text)
},
shell: {
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('shell:openExternal', url)
},
win: {
minimize: (): void => ipcRenderer.send('win:minimize'),
toggleMaximize: (): void => ipcRenderer.send('win:toggle-maximize'),
@@ -67,6 +73,7 @@ const api = {
},
page: {
snapshot: (url: string): Promise<string | null> => ipcRenderer.invoke('page:snapshot', url),
compress: (png: Uint8Array): Promise<string | null> => ipcRenderer.invoke('page:compress', png),
fetchMeta: (url: string): Promise<{ title: string; desc: string } | null> =>
ipcRenderer.invoke('page:fetchMeta', url)
},
@@ -221,8 +221,12 @@ export default function Ribbon(props: {
icon: m.icon,
label: m.label,
title: m.desc,
type: props.configMenu === m.id ? ('accent' as const) : undefined,
type: !m.href && props.configMenu === m.id ? ('accent' as const) : undefined,
action: () => {
if (m.href) {
void window.api.shell.openExternal(m.href)
return
}
setView('config')
props.onConfigMenu(m.id)
}
@@ -1,4 +1,5 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { useStore } from '../store'
export interface WebFrameHandle {
reload: () => void
@@ -31,8 +32,11 @@ const WebFrame = forwardRef<WebFrameHandle, WebFrameProps>(function WebFrame(
wv.style.height = '100%'
host.appendChild(wv)
wvRef.current = wv
// 注册当前预览 webview,供截图直接捕获预览区域内容
useStore.getState().setPreviewWv(wv)
wv.src = src
return () => {
useStore.getState().setPreviewWv(null)
wv.remove()
wvRef.current = null
}
@@ -16,7 +16,7 @@ export default function AboutPanel(): React.JSX.Element {
<div className="about-logo">🌾</div>
<div className="about-title">{settings?.projectName ?? '智慧农业大数据可视化控制中心'}</div>
<div className="about-ver">
Version {settings?.projectVersion ?? '0.2.0'}
Version {settings?.projectVersion ?? '1.5.1'}
{info?.appVersion ? ` · 安装包 ${info.appVersion}` : ''}
</div>
<p className="about-desc">
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useStore } from '../../store'
import { toast } from '../../toast'
import { nextMinor } from '../../utils'
export default function ProjectInfo(): React.JSX.Element {
const settings = useStore((s) => s.settings)
@@ -27,20 +28,27 @@ export default function ProjectInfo(): React.JSX.Element {
}, [settings])
const save = async (): Promise<void> => {
await st().updateSettings({ projectName: name.trim() || '智慧农业大数据可视化控制中心', projectVersion: ver.trim() || '0.2.0', jsonPassword: pwd })
await st().updateSettings({ projectName: name.trim() || '智慧农业大数据可视化控制中心', projectVersion: ver.trim() || '1.5.1', jsonPassword: pwd })
toast('项目信息与 JSON 密码已保存', 'ok')
}
const copyPath = async (): Promise<void> => {
if (!filePath) return
try {
await navigator.clipboard.writeText(filePath)
await window.api.clipboard.writeText(filePath)
toast('工程路径已复制', 'ok')
} catch {
toast('复制失败,请手动选择复制', 'warn')
}
}
const bumpMinor = async (): Promise<void> => {
const nv = nextMinor(ver || '1.5.1')
setVer(nv)
await st().updateSettings({ projectVersion: nv })
toast('次版本 +1 → ' + nv, 'ok')
}
return (
<div className="cfg-section">
<div className="cfg-card">
@@ -52,7 +60,12 @@ export default function ProjectInfo(): React.JSX.Element {
</div>
<div className="cfg-field">
<label></label>
<input className="cfg-input" value={ver} onChange={(e) => setVer(e.target.value)} placeholder="例如:0.2.0" />
<div className="cfg-ver">
<input className="cfg-input" value={ver} onChange={(e) => setVer(e.target.value)} placeholder="例如:1.5.1" />
<button className="btn" title="次版本编号 +11.5.1 → 0.3.0" onClick={() => void bumpMinor()}>
+1
</button>
</div>
</div>
<p className="cfg-tip"> JSONappName / version</p>
</div>
@@ -1,11 +1,13 @@
/** 配置页菜单项标识 */
export type ConfigMenu = 'info' | 'remote' | 'shortcut' | 'publish' | 'device' | 'about'
export type ConfigMenu = 'info' | 'remote' | 'shortcut' | 'publish' | 'device' | 'wenchuanyi' | 'about'
export interface ConfigMenuMeta {
id: ConfigMenu
icon: string
label: string
desc: string
/** 外部链接:配置时点击用系统浏览器打开,不切换配置页 */
href?: string
}
/** 配置菜单定义(作为 Ribbon 上的按钮排列) */
@@ -15,5 +17,6 @@ export const CONFIG_MENUS: ConfigMenuMeta[] = [
{ id: 'shortcut', icon: '🖱️', label: '快捷方式', desc: '桌面快捷方式:打开 / 编辑 / 发布' },
{ id: 'publish', icon: '🚀', label: '发布设置', desc: '快捷方式③的默认发布配置' },
{ id: 'device', icon: '🔌', label: '设备测试', desc: '键盘 / 音箱 / 麦克风 / 屏幕测试' },
{ id: 'wenchuanyi', icon: '📥', label: '文传易', desc: '用系统浏览器打开文传易网站', href: 'http://wenchuanyi.bbitcn.net/' },
{ id: 'about', icon: '️', label: '关于', desc: '版本、简介、运行环境与帮助' }
]
+11
View File
@@ -6,7 +6,18 @@ declare namespace Electron {
reload(): void
getURL(): string
getWebContentsId(): number
isLoading(): boolean
capturePage(): Promise<Electron.NativeImage>
setAttribute(name: string, value: string): void
getAttribute(name: string): string | null
}
interface NativeImage {
isEmpty(): boolean
toPNG(): Buffer
toJPEG(quality: number): Buffer
toDataURL(): string
getSize(): { width: number; height: number }
resize(options: { width?: number; height?: number; quality?: string }): NativeImage
}
}
+34 -5
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'
import type { AppSettings, LoadPayload, Page, ProjectJson, ScreenInfo, ViewMode } from './types'
import { defaultPages, jsonToPages, nextPageId, pageSrc, pagesToJson } from './utils'
import { defaultPages, jsonToPages, nextPageId, nextPatch, pageSrc, pagesToJson } from './utils'
import { toast } from './toast'
import { askPassword } from './passwordState'
@@ -15,6 +15,8 @@ interface AppState {
settings: AppSettings | null
/** 工程文件操作进行中(打开/另存等),用于防抖防重入 */
opening: boolean
/** 编辑区预览 webview 引用(用于截图捕获实际预览内容) */
previewWv: Electron.WebviewTag | null
// actions
setView: (v: ViewMode) => void
selectPage: (i: number) => void
@@ -25,6 +27,7 @@ interface AppState {
movePage: (dir: number) => void
delPage: () => void
capturePreview: (index?: number) => Promise<void>
setPreviewWv: (wv: Electron.WebviewTag | null) => void
resetOrder: () => void
setScreens: (s: ScreenInfo[]) => void
setZoom: (z: number) => void
@@ -78,6 +81,7 @@ export const useStore = create<AppState>((set, get) => {
screens: [],
zoom: 100,
settings: null,
previewWv: null,
opening: false,
setView: (v) => set({ view: v }),
@@ -157,7 +161,27 @@ export const useStore = create<AppState>((set, get) => {
}
toast('正在截图…', 'info')
try {
const data = await window.api.page.snapshot(src)
// 优先截取编辑区预览 webview(所见即所得);非当前预览页时回退到按 URL 截图
let data: string | null = null
const wv = get().previewWv
if (at === get().curIdx && wv) {
await new Promise<void>((resolve) => {
if (!wv.isLoading()) {
resolve()
return
}
const onStop = (): void => {
wv.removeEventListener('did-stop-loading', onStop)
resolve()
}
wv.addEventListener('did-stop-loading', onStop)
setTimeout(resolve, 5000)
})
const img = await wv.capturePage()
data = await window.api.page.compress(img.toPNG())
} else {
data = await window.api.page.snapshot(src)
}
if (data) {
if (data === p.preview) {
toast('页面内容无变化,缩略图已是最新', 'info')
@@ -186,6 +210,7 @@ export const useStore = create<AppState>((set, get) => {
},
setScreens: (s) => set({ screens: s }),
setZoom: (z) => set({ zoom: Math.min(160, Math.max(50, z)) }),
setPreviewWv: (wv) => set({ previewWv: wv }),
loadSettings: async () => {
const s = await window.api.settings.get()
@@ -218,21 +243,25 @@ export const useStore = create<AppState>((set, get) => {
save: async () => {
const { filePath, pages, settings } = get()
const meta = { appName: settings?.projectName, version: settings?.projectVersion }
// 每保存一次,修订号 +1
const newVer = nextPatch(settings?.projectVersion || '1.5.1')
const meta = { appName: settings?.projectName, version: newVer }
const data = JSON.stringify(pagesToJson(pages, meta), null, 2)
const pwd = settings?.jsonPassword || undefined
try {
if (filePath) {
const r = await window.api.project.save(filePath, data, pwd)
if (r) {
await get().updateSettings({ projectVersion: newVer })
set({ dirty: false })
toast('工程已保存', 'ok')
toast('工程已保存 · 修订号 v' + newVer, 'ok')
}
} else {
const path = await window.api.project.saveAs(data, pwd)
if (path) {
await get().updateSettings({ projectVersion: newVer })
set({ filePath: path, dirty: false })
toast('工程已保存为 ' + path.split(/[\\/]/).pop(), 'ok')
toast('工程已保存为 ' + path.split(/[\\/]/).pop() + ' · 修订号 v' + newVer, 'ok')
}
}
} catch (err) {
@@ -1293,6 +1293,15 @@ select {
.cfg-path .cfg-row-value {
max-width: 70%;
}
.cfg-ver {
display: flex;
align-items: center;
gap: 8px;
}
.cfg-ver .cfg-input {
flex: 1;
min-width: 0;
}
.cfg-range {
display: flex;
align-items: center;
+28 -1
View File
@@ -46,7 +46,7 @@ export function pageSrc(p: Page): string {
export function pagesToJson(pages: Page[], meta?: { appName?: string; version?: string }): ProjectJson {
return {
appName: meta?.appName || '智慧农业大数据可视化控制中心',
version: meta?.version || '0.2.0',
version: meta?.version || '1.5.1',
pages: pages.map((p) => ({
PageID: p.id,
PageTitle: p.title,
@@ -77,6 +77,33 @@ export function jsonToPages(json: ProjectJson): Page[] {
}))
}
/** 版本号:大版本 +1(1.5.1 → 1.2.0),非数字开头原样返回 */
export function nextMajor(v: string): string {
const m = v.trim().match(/^(\d+)(.*)$/)
if (!m) return v.trim()
return String(Number(m[1]) + 1) + m[2]
}
/** 版本号:次版本 +11.5.1 → 0.3.01 → 1.1.0),非数字开头回退 '1.5.1' */
export function nextMinor(v: string): string {
const m = v.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/)
if (!m) return '1.5.1'
const major = m[1]
const minor = m[2] ?? '0'
const patch = m[3] ?? '0'
return `${major}.${Number(minor) + 1}.${patch}`
}
/** 版本号:修订号 +11.5.1 → 0.2.10.2 → 0.2.11 → 1.0.1),非数字开头回退 '1.5.1' */
export function nextPatch(v: string): string {
const m = v.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/)
if (!m) return '1.5.1'
const major = m[1]
const minor = m[2] ?? '0'
const patch = m[3] ?? '0'
return `${major}.${minor}.${Number(patch) + 1}`
}
export function defaultPages(): Page[] {
return [
{ id: 'P-001', title: '农业数据总览', desc: '全基地农业数据总览', screen: 1, ctrl: 1, display: true, url: 'sample-screen.html', params: 'no=1\ntitle=农业数据总览' },
@@ -18,7 +18,7 @@ export default function Canvas(): React.JSX.Element {
const copyUrl = async (): Promise<void> => {
if (!baseUrl) return
try {
await navigator.clipboard.writeText(baseUrl)
await window.api.clipboard.writeText(baseUrl)
toast('地址已复制', 'ok')
} catch {
toast('复制失败,请手动选择复制', 'warn')