feat: 迁移 screenshot-modals.js 和 md-viewer.html 至 _templates/ 目录

- md-viewer.html 添加 <base href="../"> 修复子目录相对路径
- screenshot-modals.js 新增 fullPage 长图截取支持,action 改为可选

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
冯普
2026-05-26 17:59:30 +08:00
co-authored by Claude Opus 4.6
parent 49784629dc
commit 6e05f10396
3 changed files with 682 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="../">
<title>文档预览</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root {
--primary: #1677FF;
--primary-light: #E6F7FF;
--text-primary: #1a1a2e;
--text-secondary: #5a607f;
--text-placeholder: #999;
--border: #e8ecf1;
--bg: #ffffff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', 'Segoe UI', sans-serif; background: #f5f7fa; color: var(--text-primary); }
/* 顶部导航栏 */
.topbar { position: sticky; top: 0; z-index: 100; background: var(--bg); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.topbar__back { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: var(--primary); cursor: pointer; text-decoration: none; padding: 4px 10px; border-radius: 6px; transition: background .15s; border: none; background: none; }
.topbar__back:hover { background: var(--primary-light); }
.topbar__title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.topbar__path { font-size: 12px; color: var(--text-placeholder); margin-left: auto; }
/* 内容区 */
.content { max-width: 900px; margin: 0 auto; padding: 32px 40px 60px; background: var(--bg); min-height: calc(100vh - 50px); font-size: 14px; line-height: 1.8; }
.loading { display: flex; align-items: center; justify-content: center; height: 300px; color: var(--text-placeholder); font-size: 14px; }
.error { color: #ff4d4f; }
/* Markdown 渲染样式 */
.content h1 { font-size: 24px; font-weight: 800; margin: 0 0 16px; padding-bottom: 10px; border-bottom: 2px solid var(--border); }
.content h2 { font-size: 20px; font-weight: 700; margin: 32px 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.content h3 { font-size: 17px; font-weight: 600; margin: 24px 0 8px; }
.content h4 { font-size: 15px; font-weight: 600; margin: 20px 0 6px; }
.content p { font-size: 14px; line-height: 1.8; margin: 8px 0; }
.content ul, .content ol { padding-left: 20px; margin: 8px 0; font-size: 14px; line-height: 1.8; }
.content table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
.content th { background: #fafafa; font-weight: 600; text-align: left; padding: 8px 12px; border: 1px solid var(--border); }
.content td { padding: 8px 12px; border: 1px solid var(--border); }
.content code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; }
.content pre { background: #f6f8fa; border-radius: 8px; padding: 16px; overflow-x: auto; margin: 12px 0; }
.content pre code { background: none; padding: 0; font-size: 13px; line-height: 1.6; }
.content blockquote { border-left: 4px solid var(--primary); padding: 8px 16px; margin: 12px 0; background: var(--primary-light); border-radius: 0 4px 4px 0; }
.content blockquote p { color: var(--text-secondary); margin: 0; }
.content strong { color: var(--text-primary); }
.content hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
.content img { max-width: 100%; height: auto; border-radius: 8px; border: 1px solid var(--border); margin: 8px 0; cursor: pointer; transition: opacity .15s; }
.content img:hover { opacity: .85; }
/* Lightbox 图片放大遮罩 */
.lightbox { display: none; position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,.75); align-items: center; justify-content: center; cursor: zoom-out; }
.lightbox.active { display: flex; }
.lightbox img { max-width: 92vw; max-height: 92vh; border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,.4); }
</style>
</head>
<body>
<div class="topbar">
<button class="topbar__back" onclick="window.close();history.back()">← 返回</button>
<span class="topbar__title" id="doc-title">文档预览</span>
<span class="topbar__path" id="doc-path"></span>
</div>
<div class="content" id="content">
<div class="loading">加载中...</div>
</div>
<!-- Lightbox 图片放大遮罩 -->
<div class="lightbox" id="lightbox" onclick="this.classList.remove('active')">
<img id="lightbox-img" src="" alt="放大预览">
</div>
<script>
const params = new URLSearchParams(location.search);
const filePath = params.get('file');
const title = params.get('title');
if (title) {
document.getElementById('doc-title').textContent = title;
document.title = title + ' - 文档预览';
}
if (filePath) {
document.getElementById('doc-path').textContent = filePath;
fetch(filePath)
.then(res => {
if (!res.ok) throw new Error('文件未找到 (' + res.status + ')');
return res.text();
})
.then(md => {
/* 检测是否为自带 HTML 结构的文件(如共性需求说明.md) */
const isRawHtml = /^\s*<(?:style|div|!doctype|html)/i.test(md);
if (isRawHtml) {
/* 为标题生成锚点 id(marked 新版不再自动生成) */
function addHeadingIds(html) {
return html.replace(/<h([1-6])>([\s\S]*?)<\/h\1>/g, function(_, lvl, inner) {
var plain = inner.replace(/<[^>]*>/g, '');
var id = plain.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, '').trim().replace(/\s+/g, '-');
return '<h' + lvl + ' id="' + id + '">' + inner + '</h' + lvl + '>';
});
}
let processed = md;
if (typeof marked !== 'undefined') {
processed = md.replace(/(^[\s\S]*?<div id="main-content">)([\s\S]*?)(<\/div>\s*$)/i, function(_, before, mdBody, after) {
return before + addHeadingIds(marked.parse(mdBody)) + after;
});
processed = processed.replace(/(<div id="toc-container">)([\s\S]*?)(<\/div>)/, function(_, before, tocBody, after) {
return before + marked.parse(tocBody) + after;
});
}
/* iframe 内注入:表格框线 + 排版样式 */
const extraCss = '<style>'
+ 'html{scroll-behavior:smooth}'
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue","Segoe UI",sans-serif}'
+ '#main-content table{width:100%;border-collapse:collapse;margin:12px 0;font-size:14px}'
+ '#main-content th{background:#fafafa;font-weight:600;text-align:left;padding:8px 12px;border:1px solid #e8ecf1}'
+ '#main-content td{padding:8px 12px;border:1px solid #e8ecf1}'
+ '#main-content h1{font-size:24px;font-weight:800;margin:0 0 16px;padding-bottom:10px;border-bottom:2px solid #e8ecf1}'
+ '#main-content h2{font-size:20px;font-weight:700;margin:32px 0 12px;padding-bottom:8px;border-bottom:1px solid #e8ecf1}'
+ '#main-content h3{font-size:17px;font-weight:600;margin:24px 0 8px}'
+ '#main-content h4{font-size:15px;font-weight:600;margin:20px 0 6px}'
+ '#main-content p{font-size:14px;line-height:1.8;margin:8px 0}'
+ '#main-content ul,#main-content ol{padding-left:20px;margin:8px 0;font-size:14px;line-height:1.8}'
+ '#main-content code{background:#f5f5f5;padding:2px 6px;border-radius:4px;font-size:13px}'
+ '#main-content blockquote{border-left:4px solid #1677FF;padding:8px 16px;margin:12px 0;background:#E6F7FF;border-radius:0 4px 4px 0}'
+ '#toc-container a.active{color:#1677FF;font-weight:600}'
+ '</style>';
/* iframe 内注入:滚动时高亮当前目录项 */
const tocScript = '<script>'
+ 'document.addEventListener("DOMContentLoaded",function(){'
+ 'var links=document.querySelectorAll("#toc-container a[href^=\\"#\\"]");'
+ 'var items=[];'
+ 'links.forEach(function(a){var id=decodeURIComponent(a.getAttribute("href").slice(1));var el=document.getElementById(id);if(el)items.push({el:el,link:a})});'
+ 'if(!items.length)return;'
+ 'function update(){var sy=window.scrollY||document.documentElement.scrollTop;var cur=items[0];'
+ 'for(var i=0;i<items.length;i++){if(items[i].el.offsetTop<=sy+80)cur=items[i]}'
+ 'links.forEach(function(a){a.classList.remove("active")});if(cur)cur.link.classList.add("active")}'
+ 'window.addEventListener("scroll",update);update()});'
+ '<\/script>';
/* 用 iframe 隔离渲染 */
document.querySelector('.topbar').style.display = 'none';
const wrap = document.getElementById('content');
wrap.style.cssText = 'max-width:none;margin:0;padding:0;min-height:100vh';
const iframe = document.createElement('iframe');
iframe.style.cssText = 'width:100%;height:100vh;border:none';
iframe.srcdoc = '<!DOCTYPE html><html><head><meta charset="UTF-8">' + extraCss + '</head><body>' + processed + tocScript + '</body></html>';
wrap.innerHTML = '';
wrap.appendChild(iframe);
} else {
/* 计算 Markdown 文件所在目录,用于重写图片相对路径 */
const mdBase = filePath.substring(0, filePath.lastIndexOf('/') + 1);
const content = document.getElementById('content');
if (typeof marked !== 'undefined') {
content.innerHTML = marked.parse(md);
/* 将图片相对路径补全为基于 Markdown 文件目录的路径 */
content.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src');
if (src && !src.startsWith('http') && !src.startsWith('/') && !src.startsWith('data:')) {
img.src = mdBase + src;
}
});
/* 绑定图片点击放大 */
content.querySelectorAll('img').forEach(img => {
img.addEventListener('click', () => {
document.getElementById('lightbox-img').src = img.src;
document.getElementById('lightbox').classList.add('active');
});
});
} else {
content.innerHTML = '<pre style="white-space:pre-wrap;font-size:14px;line-height:1.8">' + md.replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
}
}
})
.catch(err => {
document.getElementById('content').innerHTML = '<div class="loading error">加载失败:' + err.message + '<br><small style="color:var(--text-placeholder);margin-top:8px;display:block">请确认文件路径:' + filePath + '</small></div>';
});
} else {
document.getElementById('content').innerHTML = '<div class="loading">未指定文件路径</div>';
}
/* ESC 键关闭 lightbox */
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.getElementById('lightbox').classList.remove('active');
});
</script>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
/**
* 通用弹窗/状态截图脚本
*
* 使用方式:
* node screenshot-modals.js <config.json>
*
* config.json 格式:
* {
* "baseDir": "employee-app/emergency-medical",
* "tasks": [
* { "page": "resource-map.html", "output": "resource-map-simulate.jpg",
* "action": "() => { document.querySelector('.simulate-panel').style.display = 'block'; }" },
* { "page": "detail.html", "output": "detail-full.jpg", "fullPage": true }
* ]
* }
*
* task 字段说明:
* page - 页面 HTML 文件路径(相对于 baseDir
* output - 输出截图文件名
* action - 可选,截图前执行的 JS(触发弹窗/状态)
* fullPage - 可选,true 时去掉 phone-shell 高度限制,截取完整滚动内容(长图)
*
* 如果不传 config.json,则使用内置的应急就医模块配置作为示例。
*/
const puppeteer = require('puppeteer');
const path = require('path');
const fs = require('fs');
const delay = ms => new Promise(r => setTimeout(r, ms));
// 只截取 .phone-shell 元素,避免外壳背景
// fullPage=true 时去掉高度限制和溢出裁剪,截取完整内容(长图)
async function screenshotPhoneShell(page, filepath, fullPage) {
if (fullPage) {
await page.evaluate(() => {
const shell = document.querySelector('.phone-shell');
if (shell) { shell.style.height = 'auto'; shell.style.borderRadius = '0'; }
const sc = document.querySelector('.scroll-content');
if (sc) { sc.style.overflow = 'visible'; sc.style.flex = 'none'; }
});
await new Promise(r => setTimeout(r, 100));
}
const shell = await page.$('.phone-shell');
if (shell) {
await shell.screenshot({ path: filepath, type: 'jpeg', quality: 85 });
} else {
await page.screenshot({ path: filepath, type: 'jpeg', quality: 85 });
}
}
// 默认配置(应急就医模块示例)
const DEFAULT_CONFIG = {
baseDir: 'employee-app/emergency-medical',
tasks: [
{ page: 'resource-map.html', output: 'resource-map-simulate.jpg',
action: `() => { const p = document.querySelector('.simulate-panel'); if(p) p.style.display = 'block'; }` },
{ page: 'emergency-chat.html', output: 'emergency-chat-modal.jpg',
action: `() => { const m = document.querySelector('.modal-overlay'); if(m){m.style.display='flex';m.classList.add('show');} }` },
{ page: 'emergency-chat.html', output: 'emergency-chat-voice.jpg',
action: `() => { const btns = document.querySelectorAll('[class*="voice"]'); btns.forEach(b => b.click()); }` },
{ page: 'emergency-chat.html', output: 'emergency-chat-plus.jpg',
action: `() => { const p = document.querySelector('.plus-panel'); if(p){p.style.display='grid';p.classList.add('show');} }` },
{ page: 'appointment-detail-pending.html', output: 'appointment-detail-pending-modal.jpg',
action: `() => { const m = document.querySelector('.modal-mask'); if(m){m.style.display='flex';m.classList.add('modal-mask--show');} }` },
{ page: 'appointment-detail-confirmed.html', output: 'appointment-detail-confirmed-modal.jpg',
action: `() => { const m = document.querySelector('.modal-mask'); if(m){m.style.display='flex';m.classList.add('modal-mask--show');} }` }
]
};
(async () => {
// 读取配置
let config = DEFAULT_CONFIG;
const configArg = process.argv[2];
if (configArg && fs.existsSync(configArg)) {
config = JSON.parse(fs.readFileSync(configArg, 'utf-8'));
console.log(`Using config: ${configArg}`);
} else {
console.log('Using default config (emergency-medical)');
}
const baseDir = path.resolve(config.baseDir);
const baseUrl = 'file:///' + baseDir.replace(/\\/g, '/');
const outDir = path.join(baseDir, 'doc', 'screenshots');
// 确保输出目录存在
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setViewport({ width: 500, height: 900, deviceScaleFactor: 1 });
for (const task of config.tasks) {
await page.goto(`${baseUrl}/${task.page}`, { waitUntil: 'networkidle0' });
// 执行 action 触发弹窗/状态(action 可选)
if (task.action) {
await page.evaluate(new Function('return (' + task.action + ')()'));
await delay(300);
}
await screenshotPhoneShell(page, path.join(outDir, task.output), task.fullPage);
console.log(`Done: ${task.output}`);
}
await browser.close();
console.log(`\nAll ${config.tasks.length} screenshots saved to: ${outDir}`);
})();
+392
View File
@@ -0,0 +1,392 @@
# 本地服务器独立微服务化方案
> **项目**:智能餐养系统 - 边缘服务节点重构
> **版本**:v1.0
> **日期**:2026-05-26
> **作者**:技术团队
> **范围**:面向技术团队的设计与落地方案
> **周期**:1 个月落地,边跑边迁
---
## 一、定位与边界
**本地服务器 ≠ 业务系统**。本次重构将其重新定位为:**部署在每个食堂边缘的独立微服务,只做"硬件 ↔ 云端"的可靠中转桥梁**,与现有"健康长庆"业务系统、小程序后端、admin 后台**完全解耦**。
| 做(In Scope) | 不做(Out of Scope) | 不混用(独立部署) |
| --- | --- | --- |
| 任务化数据中转 | 业务规则、菜品算法 | 不依赖小程序后端 |
| 任务链状态管理与重试 | 用户登录鉴权 | 不调用四合一业务接口 |
| 终端设备接入与协议适配 | 报表/统计/分析 | 不耦合 admin 业务模块 |
| 本地资源缓存(视频/人脸/升级包) | 任何 UI 业务逻辑 | 独立进程、独立升级 |
| 设备远程升级 | | |
**一句话原则**:本地服务器只回答两个问题——*云端要的数据收齐了吗?云端下发的指令落地了吗?* 其他一概不管。
---
## 二、总体架构
<!-- APPEND_HERE -->
### 2.1 系统拓扑
```mermaid
flowchart TB
subgraph Cloud["云端"]
CloudAPI[云端任务网关<br>Spring Boot]
CloudDB[(任务主库<br>PostgreSQL)]
OpsUI[运维后台<br>Vue3 + Ant Design Vue<br>复用 admin 技术栈]
end
subgraph Edge["食堂本地 - 每个食堂一套"]
LS[本地服务器微服务<br>Spring Boot 独立 JAR]
LSDB[(本地任务库<br>SQLite)]
Cache[本地资源缓存<br>视频/人脸库/升级包]
end
subgraph Devices["终端硬件"]
T1[点餐终端]
T2[入库秤/配比秤]
T3[智能货柜/绑盘机]
T4[人脸设备]
T5[显示大屏]
end
OpsUI --> CloudAPI
CloudAPI <-->|MQTT QoS2 持久会话| LS
CloudAPI --- CloudDB
LS --- LSDB
LS --- Cache
LS <-->|HTTP/MQTT 局域网| T1
LS <-->|HTTP/MQTT 局域网| T2
LS <-->|HTTP/MQTT 局域网| T3
LS <-->|HTTP/MQTT 局域网| T4
LS -->|本地 HTTP / RTSP| T5
```
### 2.2 通信协议选型(针对弱网)
| 链路 | 协议 | 选型理由 |
| --- | --- | --- |
| 云端 ↔ 本地服务器 | **MQTT QoS 2 + Persistent Session**(EMQX) | 弱网友好、断线自动续传、消息至少一次且不重复 |
| 大文件下发(视频/升级包) | HTTPS 断点续传(Range) | 弱网重连不重头,降低带宽浪费 |
| 本地服务器 ↔ 终端 | HTTP / MQTT(局域网) | 局域网稳定,简单优先,按设备类型适配 |
| 运维后台 ↔ 云端 | HTTPS REST | 复用 admin 现有 axios + token 鉴权 |
### 2.3 技术栈
| 模块 | 选型 | 备注 |
| --- | --- | --- |
| **云端任务网关** | Spring Boot 3 + Spring Integration MQTT | 与现有 Java 后端栈一致 |
| **本地服务器微服务** | Spring Boot 3,打包成单 JAR | 一键部署,内存占用控制在 512MB 以内 |
| **MQTT Broker** | EMQX 5(集群) | 支持 QoS 2、共享订阅、规则引擎 |
| **云端数据库** | **PostgreSQL 15** ✅ 推荐 / MySQL 8 备选 | PG 的 JSONB 对 `target_scope` 等字段更友好,CTE/窗口函数利于运维查询 |
| **本地任务库** | SQLite + WAL 模式 | 零运维,适合边缘场景,单文件备份 |
| **运维后台** | Vue 3 + Vite + TypeScript + Ant Design Vue | **复用现有 admin 工程技术栈与登录体系** |
| **配置中心 / 注册** | 暂不引入,本地服务器走配置文件 + 远程下发 | 边缘节点数量级小,过早引入 Nacos 增加复杂度 |
| **部署** | Docker 镜像 + 一键安装脚本 | 边缘节点需支持离线部署 |
---
## 三、核心机制:全链路任务化
### 3.1 设计原则
每一次数据流转(无论下行还是上行)都是一个**有状态、可追溯、可重试**的任务。云端与本地各持一份任务记录,通过 `task_id` 关联,任意时刻**任意任务卡在哪一跳一查就知道**。
### 3.2 任务状态机
```mermaid
stateDiagram-v2
[*] --> Created: 云端创建任务
Created --> Dispatched: 推送到本地服务器
Dispatched --> LocalReceived: 本地落库 ACK
LocalReceived --> TerminalDispatched: 分发到终端
TerminalDispatched --> TerminalDone: 终端确认完成
TerminalDone --> CloudReported: 上报云端
CloudReported --> [*]: 任务完成
Dispatched --> RetryPending: 网络中断
LocalReceived --> RetryPending: 终端离线
TerminalDispatched --> RetryPending: 终端执行失败
RetryPending --> Dispatched: 自动指数退避重试
RetryPending --> Failed: 达到最大重试次数
Failed --> Dispatched: 运维后台人工触发重试
```
**重试策略**:指数退避 `1s → 5s → 30s → 2min → 10min`,默认最多 5 次,达到上限转 `Failed` 并触发告警。
### 3.3 数据库 Schema(核心三表)
> 以下 DDL 以 PostgreSQL 为例,MySQL 版本仅类型微调(JSONB → JSON、ENUM → VARCHAR + CHECK)。
```sql
-- ============================================
-- 1. 任务主表
-- ============================================
CREATE TABLE task (
task_id UUID PRIMARY KEY,
task_type VARCHAR(32) NOT NULL,
-- 'DISH_DOWN','FACE_DOWN','UPGRADE_DOWN','VIDEO_DOWN','MEAL_UP',...
direction VARCHAR(8) NOT NULL, -- 'DOWN' / 'UP'
target_scope JSONB NOT NULL,
-- {"org_id":1,"canteen_ids":[10,11],"device_ids":["T01","T02"]}
-- 支持二级单位/食堂/设备级定向下发
payload_ref VARCHAR(255), -- 大字段指针(对象存储 key 或外部表 ID)
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
failed_count INT NOT NULL DEFAULT 0,
state VARCHAR(24) NOT NULL,
-- CREATED/DISPATCHED/LOCAL_RECEIVED/TERMINAL_DISPATCHED/
-- TERMINAL_DONE/CLOUD_REPORTED/RETRY_PENDING/FAILED
retry_count INT NOT NULL DEFAULT 0,
max_retry INT NOT NULL DEFAULT 5,
next_retry_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP,
creator VARCHAR(64)
);
CREATE INDEX idx_task_state_retry ON task(state, next_retry_at);
CREATE INDEX idx_task_target ON task USING GIN (target_scope);
-- ============================================
-- 2. 任务跳转流转表(全链路审计)
-- ============================================
CREATE TABLE task_hop (
hop_id BIGSERIAL PRIMARY KEY,
task_id UUID NOT NULL REFERENCES task(task_id),
hop_name VARCHAR(24) NOT NULL,
-- CLOUD_OUT/LOCAL_IN/LOCAL_OUT/TERMINAL_IN/TERMINAL_DONE/
-- LOCAL_REPORT/CLOUD_IN
status VARCHAR(8) NOT NULL, -- 'OK','FAIL','TIMEOUT'
err_code VARCHAR(32),
err_msg TEXT,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_task_hop_task ON task_hop(task_id, occurred_at);
-- ============================================
-- 3. 任务明细表(批量任务的逐条结果)
-- ============================================
CREATE TABLE task_item (
item_id BIGSERIAL PRIMARY KEY,
task_id UUID NOT NULL REFERENCES task(task_id),
biz_key VARCHAR(128) NOT NULL, -- 业务键,例如菜品 ID、人脸 ID
status VARCHAR(8) NOT NULL, -- 'OK','FAIL','PENDING'
err_msg TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (task_id, biz_key)
);
CREATE INDEX idx_task_item_task ON task_item(task_id, status);
```
**这套表的关键能力**:
- 任意任务卡在哪一跳,一查 `task_hop` 就知道
- 1000 条菜品下发,失败的 3 条精准告诉你是哪 3 条(`task_item.status='FAIL'`)
- 本地服务器同样跑这套 Schema(SQLite 简化版),云端拉取本地 hop 状态做对账
### 3.4 可靠性三大不变量
1. **先持久化再前进**:每一跳必须本地落库成功后才回 ACK;落库失败 → 让上游重发
2. **幂等键 = `task_id` + `biz_key`**:重发不会产生重复数据;终端拿到同一个 task_id 直接覆盖原记录
3. **任务永不丢**:本地服务器即使断电重启,任务从最近持久化的状态继续往下走;运维后台可强制重放
### 3.5 接口契约(节选)
**云端 → 本地:任务下发**
```
Topic: tenant/{org_id}/canteen/{canteen_id}/task/down
QoS: 2
Payload:
{
"task_id": "uuid",
"task_type": "DISH_DOWN",
"target_scope": { "device_ids": ["T01","T02"] },
"payload_ref": "https://oss.../dish-snapshot-2026052601.json",
"items_count": 487,
"issued_at": "2026-05-26T10:00:00Z"
}
```
**本地 → 云端:跳转回执**
```
Topic: tenant/{org_id}/canteen/{canteen_id}/task/hop
Payload:
{
"task_id": "uuid",
"hop_name": "TERMINAL_DONE",
"status": "OK",
"success_count": 485,
"failed_count": 2,
"failed_items": [
{"biz_key":"DISH_1024","err_msg":"image download timeout"}
]
}
```
**本地 → 云端:就餐数据上传**
```
Topic: tenant/{org_id}/canteen/{canteen_id}/task/up
Payload: 同 task 主表结构 + items 数组
```
---
## 四、运维后台
> 复用现有 admin 工程的 **Vue 3 + Vite + TypeScript + Ant Design Vue + Pinia + axios** 技术栈,**沿用 admin 登录体系与权限模型**,作为 admin 工程内的一组独立路由模块(`/edge-ops/*`),与业务模块隔离。
### 4.1 功能模块
```
运维后台 (admin 工程内 /edge-ops 路由组)
├── 任务总览
│ ├── 实时大盘:各食堂在线状态、今日任务数、失败率、堆积量
│ └── 异常告警:失败率突增、任务堆积超阈值、设备掉线
├── 任务管理
│ ├── 任务列表(按食堂/类型/状态/时间筛选)
│ ├── 任务详情(全链路流转图 + 明细 + 错误堆栈)
│ ├── 一键重试(单任务/批量)
│ └── 手动创建任务(应急下发)
├── 设备管理
│ ├── 食堂/本地服务器健康状态
│ ├── 终端设备清单(在线、版本、最后心跳)
│ └── 远程软件升级触发
├── 资源管理
│ ├── 视频/人脸库/升级包上传与下发
│ └── 各食堂本地缓存命中情况
├── 日志与查询
│ ├── 实时日志流(SSE 推送)
│ └── 数据库可视化查询(只读 SQL + 预设查询模板)
└── 系统配置
├── 重试策略、超时阈值
└── 二级单位/食堂组织树
```
### 4.2 关键页面示意:任务详情
```
[任务 #abc123 · 菜品下发 · 目标:总部食堂 + 5 个分食堂]
状态: ⚠ 部分失败 进度: 487/500 耗时: 2m13s
全链路流转:
✓ 云端创建 10:23:01
✓ 推送本地 10:23:02 (6/6 食堂)
✓ 本地落库 10:23:03 (6/6 食堂)
⚠ 终端分发 10:23:05 (5 食堂成功 / 1 食堂超时,自动重试中)
└─ 失败明细: 分食堂C - 终端 T07 离线
操作: [一键重试] [跳过失败项] [取消任务] [导出日志]
```
### 4.3 路由与权限
-`src/router/index.ts``modules` 数组中新增「边缘运维」分组,`meta.menuKey = 'edge-ops'`
- 路由命名:`/edge-ops/dashboard``/edge-ops/tasks``/edge-ops/devices``/edge-ops/resources``/edge-ops/logs``/edge-ops/config`
- 权限模型:复用 admin 已有 `role`,新增 `edge:read``edge:retry``edge:upgrade``edge:config` 四类细粒度权限
- 仅限内部运维人员访问,不对客户开放
---
## 五、1 个月落地计划(边跑边迁)
```mermaid
gantt
title 落地排期(2026-05-26 至 2026-06-22)
dateFormat YYYY-MM-DD
section 第1周 框架
项目骨架/任务模型/DB Schema :a1, 2026-05-26, 3d
云端↔本地通信层(MQTT QoS2) :a2, after a1, 4d
section 第2周 三大功能迁移
菜品/营养数据下发改造 :b1, after a2, 3d
就餐数据上传改造 :b2, after a2, 3d
视频资源下发改造 :b3, after b1, 4d
section 第3周 运维后台 + 联调
运维后台前后端 :c1, after b3, 5d
全链路联调 + 弱网压测 :c2, after b3, 7d
section 第4周 灰度 + 切换
单食堂灰度试运行 :d1, after c2, 3d
分批切换 + 监控 + 回滚预案 :d2, after d1, 4d
```
### 边跑边迁策略
- **新老并存**:旧 MQ 链路保留,新任务系统**双写一段时间**(老链路真实下发,新链路影子运行)
- **每日对账**:跑对账脚本比对新老链路数据一致性,差异 > 0 立即告警
- **按食堂灰度**:第 4 周第 1-3 天选 1-2 个食堂切换,稳定后批量推
- **回滚开关**:每个食堂有独立的 `use_new_pipeline` 开关,出问题秒级切回老链路
- **冻结老链路功能**:迁移期间老链路只修 P0 bug,不再加新功能
### 验收标准
| 指标 | 目标 |
| --- | --- |
| 弱网模拟丢包 30% 下任务最终成功率 | ≥ 99.9% |
| 任务平均端到端延迟(局域网正常) | ≤ 3s |
| 任意失败任务可定位到具体跳转 + 失败明细 | 100% |
| 运维后台一键重试可用率 | 100% |
| 灰度食堂连续 3 天无 P0 故障 | 通过 |
---
## 六、风险与对策
| 风险 | 影响 | 对策 |
| --- | --- | --- |
| 弱网导致云端推送积压 | 数据延迟 | MQTT 持久会话 + 本地服务器主动拉取兜底 + 队列水位告警 |
| 本地服务器断电/磁盘损坏 | 任务丢失 | SQLite 启用 WAL + 每日云端快照备份 + 关键任务云端可重放 |
| 终端固件升级失败变砖 | 设备瘫痪 | 双分区 A/B 升级 + 失败自动回滚 + 升级前强制健康检查 |
| 1 个月排期紧张 | 延期 | **第 1 周架构定型不可压缩**;运维后台 V1 可砍范围(先保任务列表 + 重试 + 日志,大盘和告警 V1.5 补) |
| 与四合一系统数据不一致 | 业务事故 | 双写期间每日对账脚本,差异 > 0 告警;切换前必须对账连续 3 天为 0 |
| MQTT Broker 单点 | 整体瘫痪 | EMQX 至少双节点集群 + VIP/DNS 漂移 |
| 边缘节点版本不一致 | 兼容问题 | 本地服务器协议层做版本协商,云端兼容旧版本 N-1 |
---
## 七、待办与开放问题
1. **EMQX 部署形态**:云端机房自建 vs 公有云托管,需运维侧拉齐
2. **对象存储**:大文件(视频/升级包)走哪个 OSS,断点续传 SDK 选型
3. **告警通道**:钉钉机器人 / 企业微信 / 短信,需明确接入哪一种
4. **灰度食堂选择**:建议选 1 个网络条件好的 + 1 个网络条件差的,覆盖两种典型场景
5. **历史任务数据迁移**:老 MQ 在途消息如何处理,是否需要补录到新任务表
---
## 八、附录
### 8.1 与现有 admin 工程的接入点
- **新增路由模块**:`src/views/edge-ops/`(dashboard / tasks / devices / resources / logs / config)
- **新增 API 聚合**:`src/api/edge-ops.ts`,baseURL `/api/edge`,走云端任务网关
- **菜单配置**:`MainLayout.vue` 新增「边缘运维」一级菜单,key = `edge-ops`
- **权限**:复用 `src/stores/user.ts` 的角色与权限校验,新增四个权限位
- **登录态**:完全沿用 admin 现有 token + axios 拦截器机制,**不新建鉴权体系**
### 8.2 文件清单(新增)
```
zhican/admin/
├── src/views/edge-ops/
│ ├── dashboard/index.vue
│ ├── tasks/index.vue
│ ├── tasks/detail.vue
│ ├── devices/index.vue
│ ├── resources/index.vue
│ ├── logs/index.vue
│ └── config/index.vue
├── src/api/edge-ops.ts
└── docs/本地服务器独立微服务化方案.md (本文件)
服务端(独立工程):
zhican-edge/
├── edge-cloud-gateway/ 云端任务网关 Spring Boot
├── edge-local-server/ 本地服务器 Spring Boot(单 JAR)
└── edge-common/ 协议/DTO/任务模型共享包
```