v1.5.4: 椤圭洰淇℃伅鍏ㄩ噺淇濆瓨淇+鍏抽棴鍊掕鏃跺脊绐?鍚姩鎭㈠涓婃宸ョ▼+绌虹櫧榛樿

This commit is contained in:
2026-08-25 01:05:35 +08:00
parent 5e6ffa85bf
commit c2d5e1c7da
14 changed files with 367 additions and 91 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smart-agri-center",
"version": "1.5.1",
"version": "1.5.4",
"description": "智慧农业大数据可视化控制中心(Electron + React + TypeScript",
"main": "./out/main/index.js",
"author": "FanHongCai",
+42 -27
View File
@@ -129,10 +129,10 @@ export function registerIpc(): void {
})
// ===== 工程文件(带 JSON 读写密码)=====
/** 记住用户最后一次选择/保存工程文件夹 */
const rememberDir = async (p: string): Promise<void> => {
/** 记住用户最后一次选择/保存工程文件夹 + 文件完整路径,供启动时自动恢复) */
const rememberProject = async (p: string): Promise<void> => {
try {
await saveSettings({ lastDir: dirname(p) })
await saveSettings({ lastDir: dirname(p), lastProjectPath: p })
} catch {
// 记录失败不影响主流程
}
@@ -151,7 +151,7 @@ export function registerIpc(): void {
})
if (r.canceled || !r.filePath) return null
const res = await writeProjectFile(r.filePath, payload.data, pwd)
if (res.ok) await rememberDir(r.filePath)
if (res.ok) await rememberProject(r.filePath)
return res.ok ? r.filePath : null
})
@@ -160,6 +160,7 @@ export function registerIpc(): void {
const settings = await loadSettings()
const pwd = payload.password ?? settings.jsonPassword
const res = await writeProjectFile(payload.path, payload.data, pwd)
if (res.ok) await rememberProject(payload.path)
return res.ok ? payload.path : null
})
@@ -175,14 +176,19 @@ export function registerIpc(): void {
if (r.canceled || !r.filePaths[0]) return null
const pwd = password ?? settings.jsonPassword
const res = await readProjectFile(r.filePaths[0], pwd)
await rememberDir(r.filePaths[0])
await rememberProject(r.filePaths[0])
return res
})
// 打开指定路径工程(用于密码输入重试 / 快捷方式场景)
ipcMain.handle('project:openAt', (_e, payload: { path: string; password?: string }) =>
readProjectFile(payload.path, payload.password ?? '')
)
// 打开指定路径工程(用于密码输入重试 / 快捷方式场景 / 启动自动恢复
ipcMain.handle('project:openAt', async (_e, payload: { path: string; password?: string }) => {
const res = await readProjectFile(payload.path, payload.password ?? '')
await rememberProject(payload.path)
return res
})
// 返回上次编辑的工程文件路径(启动时自动恢复用),无则返回 null
ipcMain.handle('project:lastPath', async () => (await loadSettings()).lastProjectPath || null)
// ===== 应用设置 =====
ipcMain.handle('settings:get', () => loadSettings())
@@ -328,32 +334,41 @@ export function registerIpc(): void {
let dirty = false
let forceQuit = false
/** 关闭倒计时秒数:到点后自动退出主程序 */
const QUIT_COUNTDOWN = 10
let quitGuardTimer: NodeJS.Timeout | null = null
function setDirtyFlag(d: boolean): void {
dirty = d
}
export function installCloseGuard(win: BrowserWindow): void {
// 渲染层确认退出 / 倒计时结束 → 真正退出
ipcMain.removeAllListeners('app:quitForce')
ipcMain.on('app:quitForce', () => {
forceQuit = true
if (quitGuardTimer) {
clearTimeout(quitGuardTimer)
quitGuardTimer = null
}
closeAllScreens()
if (!win.isDestroyed()) win.close()
})
win.on('close', (e) => {
if (forceQuit) return
e.preventDefault()
const warn = dirty ? '当前工程有未保存的修改。\n\n' : ''
void dialog
.showMessageBox(win, {
type: 'warning',
title: '确认退出',
message: `${warn}确定要退出智慧农业大数据可视化控制中心吗?`,
buttons: ['退出', '取消'],
defaultId: 1,
cancelId: 1
})
.then((r) => {
if (r.response === 0) {
forceQuit = true
// 先关闭所有播放中的子页面(屏幕窗体/轮播/探测/主屏播放),再关闭主窗口
closeAllScreens()
win.close()
}
})
// 通知渲染层弹出倒计时确认框(由渲染层决定是否退出)
if (!win.isDestroyed()) win.webContents.send('app:requestQuit')
// 兜底:若渲染层在倒计时结束后仍未响应,则强制退出,避免卡死
if (quitGuardTimer) clearTimeout(quitGuardTimer)
quitGuardTimer = setTimeout(
() => {
forceQuit = true
closeAllScreens()
if (!win.isDestroyed()) win.close()
},
(QUIT_COUNTDOWN + 5) * 1000
)
})
}
+5 -2
View File
@@ -25,16 +25,19 @@ export interface AppSettings {
publish: PublishDefaults
/** 上次打开/另存为工程的文件夹,用于记住上次位置 */
lastDir: string
/** 上次打开/保存/另存为的工程文件完整路径,用于启动时自动恢复 */
lastProjectPath: string
}
const DEFAULTS: AppSettings = {
projectName: '智慧农业大数据可视化控制中心',
projectVersion: '1.5.1',
projectVersion: '1.5.4',
jsonPassword: '',
keyThreshold: 350,
keyLockKey: 'F9',
publish: { mode: 'multi', screenIndex: 1, alsoMain: false, intervalSec: 10 },
lastDir: ''
lastDir: '',
lastProjectPath: ''
}
let cache: AppSettings | null = null
+6
View File
@@ -107,6 +107,8 @@ export interface SmartAgriApi {
save(path: string, data: string, password?: string): Promise<string | null>
open(password?: string): Promise<{ path: string; json: ProjectJson | null; error?: string } | null>
openAt(path: string, password?: string): Promise<{ path: string; json: ProjectJson | null; error?: string }>
/** 返回上次编辑的工程文件路径(启动自动恢复用),无则返回 null */
lastPath(): Promise<string | null>
}
settings: {
get(): Promise<AppSettings>
@@ -119,6 +121,10 @@ export interface SmartAgriApi {
info(): Promise<AppInfo>
consumeLoad(): Promise<LoadPayload | null>
onLoadProject(cb: (payload: LoadPayload) => void): () => void
/** 主进程拦截关闭时触发,渲染层应弹出倒计时确认框 */
onRequestQuit(cb: () => void): () => void
/** 倒计时结束或用户确认后,通知主进程强制退出 */
quitForce(): void
}
key: {
onScreenKey(cb: (n: number) => void): () => void
+5 -2
View File
@@ -54,7 +54,8 @@ const api = {
open: (password?: string): Promise<{ path: string; json: unknown | null; error?: string } | null> =>
ipcRenderer.invoke('project:open', password),
openAt: (path: string, password?: string): Promise<{ path: string; json: unknown | null; error?: string }> =>
ipcRenderer.invoke('project:openAt', { path, password })
ipcRenderer.invoke('project:openAt', { path, password }),
lastPath: (): Promise<string | null> => ipcRenderer.invoke('project:lastPath')
},
settings: {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
@@ -66,7 +67,9 @@ const api = {
app: {
info: (): Promise<unknown> => ipcRenderer.invoke('app:info'),
consumeLoad: (): Promise<unknown> => ipcRenderer.invoke('app:consumeLoad'),
onLoadProject: (cb: (payload: unknown) => void): (() => void) => on('app:loadProject', cb)
onLoadProject: (cb: (payload: unknown) => void): (() => void) => on('app:loadProject', cb),
onRequestQuit: (cb: () => void): (() => void) => on('app:requestQuit', cb),
quitForce: (): void => ipcRenderer.send('app:quitForce')
},
key: {
onScreenKey: (cb: (n: number) => void): (() => void) => on('key:switch', cb)
+44 -3
View File
@@ -4,6 +4,7 @@ import Ribbon from './components/Ribbon'
import StatusBar from './components/StatusBar'
import Toast from './components/Toast'
import { PasswordModal } from './components/PasswordModal'
import { QuitConfirmModal } from './components/QuitConfirmModal'
import EditView from './views/EditView'
import ConfigView from './views/ConfigView'
import { useStore } from './store'
@@ -16,6 +17,7 @@ export default function App(): React.JSX.Element {
const view = useStore((s) => s.view)
const pages = useStore((s) => s.pages)
const [configMenu, setConfigMenu] = useState<ConfigMenu>('info')
const [quitOpen, setQuitOpen] = useState(false)
// 监听屏幕状态变化
useEffect(() => {
@@ -40,21 +42,35 @@ export default function App(): React.JSX.Element {
)
}, [pages])
// 启动:加载应用设置 + 消费/监听快捷方式打开的项目
// 启动:加载应用设置 + 消费/监听快捷方式打开的项目;无快捷方式工程时自动恢复上次编辑的工程
useEffect(() => {
const st = useStore.getState
void st().loadSettings()
const handle = (p: unknown): void => {
const lp = p as LoadPayload | null
if (!lp?.json) return
const mode = st().loadProject(lp)
if (mode === 'publish') void applyPublishDefaults()
}
void window.api.app.consumeLoad().then(handle)
void (async () => {
await st().loadSettings()
const lp = (await window.api.app.consumeLoad()) as LoadPayload | null
if (lp?.json) {
handle(lp)
} else {
// 无快捷方式指定工程时,默认加载最后一次编辑的工程
await st().loadLastProject()
}
})()
const off = window.api.app.onLoadProject(handle)
return off
}, [])
// 主进程拦截关闭 → 弹出倒计时 / 保存确认框
useEffect(() => {
const off = window.api.app.onRequestQuit(() => setQuitOpen(true))
return off
}, [])
// 主窗口数字键切换页面
useEffect(() => {
const h = (e: KeyboardEvent): void => {
@@ -86,6 +102,31 @@ export default function App(): React.JSX.Element {
<StatusBar />
<Toast />
<PasswordModal />
{quitOpen && (
<QuitConfirmModal
seconds={10}
hasUnsaved={
useStore.getState().dirty ||
((): boolean => {
const s = useStore.getState()
return !!s.settings &&
(s.settings.projectName !== s.savedInfo.projectName ||
s.settings.projectVersion !== s.savedInfo.projectVersion ||
s.settings.jsonPassword !== s.savedInfo.jsonPassword)
})()
}
onSaveQuit={async () => {
setQuitOpen(false)
await useStore.getState().save()
window.api.app.quitForce()
}}
onQuit={() => {
setQuitOpen(false)
window.api.app.quitForce()
}}
onCancel={() => setQuitOpen(false)}
/>
)}
</div>
)
}
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react'
interface Props {
/** 倒计时秒数,无未保存变更到点自动退出 */
seconds: number
/** 是否存在未保存的修改(页面或项目信息),有则改为「是否保存」确认而非倒计时 */
hasUnsaved: boolean
/** 保存并退出(仅 hasUnsaved 时可用) */
onSaveQuit: () => void
/** 直接退出(放弃保存) */
onQuit: () => void
/** 取消退出 */
onCancel: () => void
}
/** 关闭主程序前的确认弹窗:
* - 无未保存变更:倒计时归零自动退出
* - 有未保存变更:提示是否保存后再退出 */
export function QuitConfirmModal({ seconds, hasUnsaved, onSaveQuit, onQuit, onCancel }: Props): React.JSX.Element {
const [left, setLeft] = useState(seconds)
useEffect(() => {
if (hasUnsaved) return // 有变更时不自动倒计时退出,需用户决策
if (left <= 0) {
onQuit()
return
}
const t = setTimeout(() => setLeft((n) => n - 1), 1000)
return () => clearTimeout(t)
}, [hasUnsaved, left, onQuit])
const pct = Math.max(0, Math.min(100, (left / seconds) * 100))
return (
<div className="modal-overlay" role="dialog" aria-modal="true">
<div className="modal quit-modal">
<div className="modal-head">
<span className="modal-title">{hasUnsaved ? '保存更改' : '正在退出'}</span>
</div>
<div className="modal-body">
{hasUnsaved ? (
<p className="m-hint">退</p>
) : (
<>
<p className="m-hint">
<b className="quit-count">{left}</b>
</p>
<div className="quit-progress">
<div className="quit-progress-bar" style={{ width: `${pct}%` }} />
</div>
<p className="m-hint"></p>
</>
)}
</div>
<div className="modal-foot">
{hasUnsaved ? (
<>
<button className="btn" onClick={onCancel}>
</button>
<button className="btn" onClick={onQuit}>
退
</button>
<button className="btn primary" onClick={onSaveQuit}>
退
</button>
</>
) : (
<>
<button className="btn" onClick={onCancel}>
</button>
<button className="btn primary" onClick={onQuit}>
退
</button>
</>
)}
</div>
</div>
</div>
)
}
@@ -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 ?? '1.5.1'}
Version {settings?.projectVersion ?? '1.5.4'}
{info?.appVersion ? ` · 安装包 ${info.appVersion}` : ''}
</div>
<p className="about-desc">
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useStore } from '../../store'
import { toast } from '../../toast'
import { nextMinor } from '../../utils'
@@ -8,7 +8,13 @@ export default function ProjectInfo(): React.JSX.Element {
const pages = useStore((s) => s.pages)
const screens = useStore((s) => s.screens)
const filePath = useStore((s) => s.filePath)
const opening = useStore((s) => s.opening)
const infoDirty = useStore((s) =>
s.settings
? s.settings.projectName !== s.savedInfo.projectName ||
s.settings.projectVersion !== s.savedInfo.projectVersion ||
s.settings.jsonPassword !== s.savedInfo.jsonPassword
: false
)
const st = useStore.getState
const [name, setName] = useState(settings?.projectName ?? '')
@@ -16,20 +22,26 @@ export default function ProjectInfo(): React.JSX.Element {
const [pwd, setPwd] = useState(settings?.jsonPassword ?? '')
const [showPwd, setShowPwd] = useState(false)
// 设置异步加载完成后同步一次
const synced = useRef(false)
// 跟随设置变化同步字段(新建/打开工程初始化项目信息时立即刷新)
useEffect(() => {
if (settings && !synced.current) {
synced.current = true
setName(settings.projectName)
setVer(settings.projectVersion)
setPwd(settings.jsonPassword)
}
if (!settings) return
setName(settings.projectName)
setVer(settings.projectVersion)
setPwd(settings.jsonPassword)
}, [settings])
const save = async (): Promise<void> => {
await st().updateSettings({ projectName: name.trim() || '智慧农业大数据可视化控制中心', projectVersion: ver.trim() || '1.5.1', jsonPassword: pwd })
toast('项目信息与 JSON 密码已保存', 'ok')
// 项目信息实时同步到设置(关闭系统时会提示是否保存)
const syncName = (v: string): void => {
setName(v)
void st().updateSettings({ projectName: v.trim() || '新建大数据可视化工程' })
}
const syncVer = (v: string): void => {
setVer(v)
void st().updateSettings({ projectVersion: v.trim() || '1.0.0' })
}
const syncPwd = (v: string): void => {
setPwd(v)
void st().updateSettings({ jsonPassword: v })
}
const copyPath = async (): Promise<void> => {
@@ -43,7 +55,7 @@ export default function ProjectInfo(): React.JSX.Element {
}
const bumpMinor = async (): Promise<void> => {
const nv = nextMinor(ver || '1.5.1')
const nv = nextMinor(ver || '1.0.0')
setVer(nv)
await st().updateSettings({ projectVersion: nv })
toast('次版本 +1 → ' + nv, 'ok')
@@ -56,12 +68,12 @@ export default function ProjectInfo(): React.JSX.Element {
<div className="cfg-card-body">
<div className="cfg-field">
<label></label>
<input className="cfg-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="例如:智慧农业大数据可视化控制中心" />
<input className="cfg-input" value={name} onChange={(e) => syncName(e.target.value)} placeholder="例如:智慧农业大数据可视化控制中心" />
</div>
<div className="cfg-field">
<label></label>
<div className="cfg-ver">
<input className="cfg-input" value={ver} onChange={(e) => setVer(e.target.value)} placeholder="例如:1.5.1" />
<input className="cfg-input" value={ver} onChange={(e) => syncVer(e.target.value)} placeholder="例如:1.0.0" />
<button className="btn" title="次版本编号 +11.5.1 → 0.3.0" onClick={() => void bumpMinor()}>
+1
</button>
@@ -84,7 +96,7 @@ export default function ProjectInfo(): React.JSX.Element {
className="cfg-input"
type={showPwd ? 'text' : 'password'}
value={pwd}
onChange={(e) => setPwd(e.target.value)}
onChange={(e) => syncPwd(e.target.value)}
placeholder="留空则不加密"
/>
<button className="btn" onClick={() => setShowPwd(!showPwd)}>
@@ -121,20 +133,7 @@ export default function ProjectInfo(): React.JSX.Element {
<span className="cfg-row-label"></span>
<span className="cfg-row-value">{screens.length > 0 ? screens.length : '未检测'}</span>
</div>
<div className="pub-actions">
<button className="btn primary" onClick={() => void save()}>
💾
</button>
<button className="btn" disabled={opening} onClick={() => void st().open()}>
{opening ? '⏳ 打开中…' : '📂 打开工程'}
</button>
<button className="btn" onClick={() => void st().save()}>
💾
</button>
<button className="btn" onClick={() => void st().saveAs()}>
📤
</button>
</div>
{infoDirty && <p className="cfg-tip info-dirty"></p>}
</div>
</div>
</div>
+101 -13
View File
@@ -13,6 +13,8 @@ interface AppState {
screens: ScreenInfo[]
zoom: number
settings: AppSettings | null
/** 上次「保存/打开/新建工程」时的项目信息快照,用于派生项目信息是否已变更 */
savedInfo: { projectName: string; projectVersion: string; jsonPassword: string }
/** 工程文件操作进行中(打开/另存等),用于防抖防重入 */
opening: boolean
/** 编辑区预览 webview 引用(用于截图捕获实际预览内容) */
@@ -33,6 +35,8 @@ interface AppState {
setZoom: (z: number) => void
loadSettings: () => Promise<AppSettings>
updateSettings: (patch: Partial<AppSettings>) => Promise<AppSettings>
/** 启动时自动恢复上次编辑的工程(无则保留示例工程) */
loadLastProject: () => Promise<void>
newProject: () => void
loadProject: (payload: LoadPayload) => 'edit' | 'publish'
save: () => Promise<void>
@@ -57,10 +61,25 @@ function blankPage(pages: Page[], id: string, title: string): Page {
}
}
/** 应用工程 JSON 到编辑状态 */
function applyProject(get: () => AppState, set: (p: Partial<AppState>) => void, path: string, json: ProjectJson): void {
/** 应用工程 JSON 到编辑状态(页面 + 项目信息随工程一起载入配置页) */
async function applyProject(get: () => AppState, set: (p: Partial<AppState>) => void, path: string, json: ProjectJson): Promise<void> {
const pages = jsonToPages(json)
set({ pages, curIdx: 0, filePath: path, dirty: false })
// 项目信息从工程文件读取,写入 settings,使配置页显示该项目的信息
const cur = get().settings
const projectName = json.projectInfo?.projectName ?? json.appName ?? cur?.projectName ?? '新建大数据可视化工程'
const projectVersion = json.projectInfo?.projectVersion ?? json.version ?? cur?.projectVersion ?? '1.0.0'
let nextSettings = cur
if (cur && (cur.projectName !== projectName || cur.projectVersion !== projectVersion)) {
nextSettings = await get().updateSettings({ projectName, projectVersion })
}
set({
pages,
curIdx: 0,
filePath: path,
dirty: false,
settings: nextSettings,
savedInfo: { projectName, projectVersion, jsonPassword: nextSettings?.jsonPassword ?? cur?.jsonPassword ?? '' }
})
try {
window.api.syncProject(pages.map((p) => ({ ctrl: p.ctrl, title: p.title, url: pageSrc(p) })))
} catch {
@@ -81,6 +100,7 @@ export const useStore = create<AppState>((set, get) => {
screens: [],
zoom: 100,
settings: null,
savedInfo: { projectName: '', projectVersion: '', jsonPassword: '' },
previewWv: null,
opening: false,
@@ -234,18 +254,33 @@ export const useStore = create<AppState>((set, get) => {
params: ''
}
set({ pages: [blank], curIdx: 0, filePath: null, dirty: true })
// 初始化配置页「项目信息」的全部字段:项目名称 / 版本号 / JSON 密码恢复默认
void get()
.updateSettings({ projectName: '新建大数据可视化工程', projectVersion: '1.0.0', jsonPassword: '' })
.then((s) =>
set({
savedInfo: { projectName: s.projectName, projectVersion: s.projectVersion, jsonPassword: s.jsonPassword }
})
)
toast('已新建工程(默认 1 个空白页面),保存时请选择位置', 'ok')
},
loadProject: (payload) => {
if (payload?.json) applyProject(get, set, payload.path, payload.json)
if (payload?.json) void applyProject(get, set, payload.path, payload.json)
return payload?.mode ?? 'edit'
},
save: async () => {
const { filePath, pages, settings } = get()
// 每保存一次,修订号 +1
const newVer = nextPatch(settings?.projectVersion || '1.5.1')
const meta = { appName: settings?.projectName, version: newVer }
const newVer = nextPatch(settings?.projectVersion || '1.5.4')
const meta = {
appName: settings?.projectName ?? '新建大数据可视化工程',
version: newVer,
projectInfo: {
projectName: settings?.projectName ?? '新建大数据可视化工程',
projectVersion: newVer
}
}
const data = JSON.stringify(pagesToJson(pages, meta), null, 2)
const pwd = settings?.jsonPassword || undefined
try {
@@ -253,14 +288,31 @@ export const useStore = create<AppState>((set, get) => {
const r = await window.api.project.save(filePath, data, pwd)
if (r) {
await get().updateSettings({ projectVersion: newVer })
set({ dirty: false })
const s = get().settings
set({
dirty: false,
savedInfo: {
projectName: s?.projectName ?? meta.projectInfo.projectName,
projectVersion: s?.projectVersion ?? newVer,
jsonPassword: s?.jsonPassword ?? ''
}
})
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 })
const s = get().settings
set({
filePath: path,
dirty: false,
savedInfo: {
projectName: s?.projectName ?? meta.projectInfo.projectName,
projectVersion: s?.projectVersion ?? newVer,
jsonPassword: s?.jsonPassword ?? ''
}
})
toast('工程已保存为 ' + path.split(/[\\/]/).pop() + ' · 修订号 v' + newVer, 'ok')
}
}
@@ -270,13 +322,29 @@ export const useStore = create<AppState>((set, get) => {
},
saveAs: async () => {
const { pages, settings } = get()
const meta = { appName: settings?.projectName, version: settings?.projectVersion }
const meta = {
appName: settings?.projectName ?? '新建大数据可视化工程',
version: settings?.projectVersion ?? '1.0.0',
projectInfo: {
projectName: settings?.projectName ?? '新建大数据可视化工程',
projectVersion: settings?.projectVersion ?? '1.0.0'
}
}
const data = JSON.stringify(pagesToJson(pages, meta), null, 2)
const pwd = settings?.jsonPassword || undefined
try {
const path = await window.api.project.saveAs(data, pwd)
if (path) {
set({ filePath: path, dirty: false })
const s = get().settings
set({
filePath: path,
dirty: false,
savedInfo: {
projectName: s?.projectName ?? meta.projectInfo.projectName,
projectVersion: s?.projectVersion ?? meta.projectInfo.projectVersion,
jsonPassword: s?.jsonPassword ?? ''
}
})
toast('工程已另存为', 'ok')
}
} catch (err) {
@@ -291,7 +359,7 @@ export const useStore = create<AppState>((set, get) => {
let r = await window.api.project.open(settings.jsonPassword || undefined)
if (!r) return
if (r.json) {
applyProject(get, set, r.path, r.json)
await applyProject(get, set, r.path, r.json)
return
}
if (r.error === '密码错误') {
@@ -299,7 +367,7 @@ export const useStore = create<AppState>((set, get) => {
if (!pwd) return
r = await window.api.project.openAt(r.path, pwd)
if (r?.json) {
applyProject(get, set, r.path, r.json)
await applyProject(get, set, r.path, r.json)
// 密码正确后记忆到设置,后续打开免输入
void get().updateSettings({ jsonPassword: pwd })
} else {
@@ -314,6 +382,26 @@ export const useStore = create<AppState>((set, get) => {
set({ opening: false })
}
},
markSaved: () => set({ dirty: false })
markSaved: () => set({ dirty: false }),
loadLastProject: async () => {
try {
const path = await window.api.project.lastPath()
if (!path) return
const pwd = get().settings?.jsonPassword || undefined
const r = await window.api.project.openAt(path, pwd)
if (r?.json) {
await applyProject(get, set, r.path, r.json)
} else if (r?.error === '密码错误') {
toast('上次编辑的工程需要密码,本次加载空白工程(可在“项目/打开”中手动打开)', 'warn')
} else {
toast('上次编辑的工程文件不可用,已加载空白工程', 'warn')
// 记录失效路径,避免每次启动都提示
void get().updateSettings({ lastProjectPath: '' })
}
} catch {
/* 静默:加载失败时保留空白工程 */
}
}
}
})
@@ -980,6 +980,33 @@ select {
font-size: 12px;
padding: 20px 0;
}
/* ============ 关闭倒计时弹窗 ============ */
.quit-modal {
width: 360px;
}
.quit-count {
color: var(--c-accent);
font-size: 16px;
font-weight: 700;
}
.quit-progress {
height: 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.quit-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--c-accent), var(--c-primary-strong));
border-radius: 6px;
transition: width 1s linear;
}
.info-dirty {
color: var(--c-accent);
font-weight: 600;
}
.m-screen-list {
display: flex;
flex-direction: column;
@@ -40,9 +40,16 @@ export interface PageFile {
PagePreview?: string
}
export interface ProjectInfo {
projectName: string
projectVersion: string
}
export interface ProjectJson {
appName?: string
version?: string
/** 项目信息(名称/版本),与工程文件关联,打开时载入、保存时写回 */
projectInfo?: ProjectInfo
pages?: PageFile[]
}
@@ -64,6 +71,8 @@ export interface AppSettings {
publish: PublishDefaults
/** 上次打开/另存为工程的文件夹(主进程记忆) */
lastDir?: string
/** 上次打开/保存/另存为的工程文件完整路径(启动时自动恢复) */
lastProjectPath?: string
}
export interface LoadPayload {
+14 -11
View File
@@ -43,10 +43,15 @@ export function pageSrc(p: Page): string {
}
}
export function pagesToJson(pages: Page[], meta?: { appName?: string; version?: string }): ProjectJson {
export function pagesToJson(
pages: Page[],
meta: { appName: string; version: string; projectInfo: { projectName: string; projectVersion: string } }
): ProjectJson {
return {
appName: meta?.appName || '智慧农业大数据可视化控制中心',
version: meta?.version || '1.5.1',
appName: meta.appName,
version: meta.version,
// 项目信息随工程文件一起保存,打开时据此还原
projectInfo: { projectName: meta.projectInfo.projectName, projectVersion: meta.projectInfo.projectVersion },
pages: pages.map((p) => ({
PageID: p.id,
PageTitle: p.title,
@@ -84,20 +89,20 @@ export function nextMajor(v: string): string {
return String(Number(m[1]) + 1) + m[2]
}
/** 版本号:次版本 +11.5.1 → 0.3.01 → 1.1.0),非数字开头回退 '1.5.1' */
/** 版本号:次版本 +11.5.1 → 0.3.01 → 1.1.0),非数字开头回退 '1.5.4' */
export function nextMinor(v: string): string {
const m = v.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/)
if (!m) return '1.5.1'
if (!m) return '1.5.4'
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' */
/** 版本号:修订号 +11.5.1 → 0.2.10.2 → 0.2.11 → 1.0.1),非数字开头回退 '1.5.4' */
export function nextPatch(v: string): string {
const m = v.trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/)
if (!m) return '1.5.1'
if (!m) return '1.5.4'
const major = m[1]
const minor = m[2] ?? '0'
const patch = m[3] ?? '0'
@@ -105,10 +110,8 @@ export function nextPatch(v: string): string {
}
export function defaultPages(): Page[] {
// 默认空白工程:不预置示例页面,首次启动 / 无上次工程记录时显示此空白工程
return [
{ id: 'P-001', title: '农业数据总览', desc: '全基地农业数据总览', screen: 1, ctrl: 1, display: true, url: 'sample-screen.html', params: 'no=1\ntitle=农业数据总览' },
{ id: 'P-002', title: '土壤墒情监测', desc: '土壤湿度与墒情分析', screen: 2, ctrl: 2, display: true, url: 'sample-screen.html', params: 'no=2\ntitle=土壤墒情监测' },
{ id: 'P-003', title: '温室环境监控', desc: '温室内环境参数实时监控', screen: 3, ctrl: 3, display: true, url: 'sample-screen.html', params: 'no=3\ntitle=温室环境监控' },
{ id: 'P-004', title: '设备运维看板', desc: '灌溉设备运维状态', screen: 1, ctrl: 4, display: true, url: 'sample-screen.html', params: 'no=4\ntitle=设备运维看板' }
{ id: 'P-001', title: '空白页面', desc: '', screen: 1, ctrl: 0, display: true, url: 'about:blank', params: '' }
]
}