feat(web): add API.md drawer preview
This commit is contained in:
parent
920e37f759
commit
a691f07e8e
|
|
@ -0,0 +1,590 @@
|
|||
# PLC Control 接口说明
|
||||
|
||||
本文档基于当前服务端路由与处理器代码整理,覆盖 HTTP API、SSE 日志流和 WebSocket 实时消息。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- 服务端默认提供静态 UI:`/ui`
|
||||
- HTTP API 前缀:`/api`
|
||||
- 公共实时 WebSocket:`/ws/public`
|
||||
- 客户端专属 WebSocket:`/ws/client/{client_id}`
|
||||
|
||||
## 通用错误响应
|
||||
|
||||
接口失败时通常返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"err_code": 400,
|
||||
"err_msg": "Invalid JSON format",
|
||||
"err_detail": null
|
||||
}
|
||||
```
|
||||
|
||||
常见状态码:
|
||||
|
||||
- `400 Bad Request`:参数错误
|
||||
- `403 Forbidden`:写入权限不足
|
||||
- `404 Not Found`:资源不存在
|
||||
- `500 Internal Server Error`:服务端内部错误
|
||||
|
||||
---
|
||||
|
||||
## Source
|
||||
|
||||
### GET `/api/source`
|
||||
|
||||
获取所有已启用数据源及其连接状态。
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "PLC-1",
|
||||
"protocol": "opcua",
|
||||
"endpoint": "opc.tcp://127.0.0.1:4840",
|
||||
"security_policy": null,
|
||||
"security_mode": null,
|
||||
"enabled": true,
|
||||
"created_at": "2026-03-20 10:00:00.000",
|
||||
"updated_at": "2026-03-20 10:00:00.000",
|
||||
"is_connected": true,
|
||||
"last_error": null,
|
||||
"last_time": "2026-03-20 10:05:00.000"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### POST `/api/source`
|
||||
|
||||
创建数据源。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "PLC-1",
|
||||
"endpoint": "opc.tcp://127.0.0.1:4840",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### PUT `/api/source/{source_id}`
|
||||
|
||||
更新数据源。
|
||||
|
||||
请求体字段均可选:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "PLC-1",
|
||||
"endpoint": "opc.tcp://127.0.0.1:4840",
|
||||
"enabled": true,
|
||||
"security_policy": "None",
|
||||
"security_mode": "None",
|
||||
"username": "user",
|
||||
"password": "pass"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok_msg": "Source updated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE `/api/source/{source_id}`
|
||||
|
||||
删除数据源。
|
||||
|
||||
成功响应:`204 No Content`
|
||||
|
||||
### POST `/api/source/{source_id}/reconnect`
|
||||
|
||||
手动重连指定数据源。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok_msg": "Source reconnected successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/source/{source_id}/browse`
|
||||
|
||||
从 OPC UA 源浏览节点并写入本地 `node` 表。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok_msg": "Browse completed",
|
||||
"total_nodes": 123
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/source/{source_id}/node-tree`
|
||||
|
||||
获取指定数据源的节点树。
|
||||
|
||||
响应字段:
|
||||
|
||||
- `id`
|
||||
- `source_id`
|
||||
- `external_id`
|
||||
- `namespace_uri`
|
||||
- `namespace_index`
|
||||
- `identifier_type`
|
||||
- `identifier`
|
||||
- `browse_name`
|
||||
- `display_name`
|
||||
- `node_class`
|
||||
- `parent_id`
|
||||
- `children`
|
||||
|
||||
---
|
||||
|
||||
## Point
|
||||
|
||||
### GET `/api/point`
|
||||
|
||||
分页获取点位列表。
|
||||
|
||||
查询参数:
|
||||
|
||||
- `source_id`:可选,按数据源过滤
|
||||
- `page`:页码
|
||||
- `page_size`:每页条数
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"node_id": "uuid",
|
||||
"name": "Temperature",
|
||||
"description": null,
|
||||
"unit": null,
|
||||
"tag_id": null,
|
||||
"created_at": "2026-03-20 10:00:00.000",
|
||||
"updated_at": "2026-03-20 10:00:00.000",
|
||||
"point_monitor": {
|
||||
"protocol": "opcua",
|
||||
"source_id": "uuid",
|
||||
"point_id": "uuid",
|
||||
"client_handle": 1001,
|
||||
"scan_mode": "subscribe",
|
||||
"timestamp": "2026-03-20 10:05:00.000",
|
||||
"quality": "good",
|
||||
"value": 12.3,
|
||||
"value_type": "float",
|
||||
"value_text": "12.3",
|
||||
"old_value": 12.1,
|
||||
"old_timestamp": "2026-03-20 10:04:59.000",
|
||||
"value_changed": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 100
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/point/{point_id}`
|
||||
|
||||
获取单个点位。
|
||||
|
||||
### GET `/api/point/{point_id}/history`
|
||||
|
||||
获取点位最近历史样本。数据来自进程内存中的环形缓冲,不是持久化历史库。
|
||||
|
||||
查询参数:
|
||||
|
||||
- `limit`:可选,默认 `120`,最大 `1000`
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-03-20 10:05:00.000",
|
||||
"quality": "good",
|
||||
"value": 12.3,
|
||||
"value_text": "12.3",
|
||||
"value_number": 12.3
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `value_number` 便于前端直接绘图
|
||||
- 非数值型点位时,`value_number` 可能为 `null`
|
||||
|
||||
### PUT `/api/point/{point_id}`
|
||||
|
||||
更新点位元数据。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Temperature",
|
||||
"description": "Room temperature",
|
||||
"unit": "°C",
|
||||
"tag_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE `/api/point/{point_id}`
|
||||
|
||||
删除单个点位。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok_msg": "Point deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/point/batch`
|
||||
|
||||
根据节点批量创建点位。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"node_ids": ["uuid1", "uuid2"]
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_count": 2,
|
||||
"failed_count": 0,
|
||||
"failed_node_ids": [],
|
||||
"created_point_ids": ["uuid3", "uuid4"]
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE `/api/point/batch`
|
||||
|
||||
批量删除点位。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"point_ids": ["uuid1", "uuid2"]
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
### PUT `/api/point/batch/set-tags`
|
||||
|
||||
批量设置点位标签。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"point_ids": ["uuid1", "uuid2"],
|
||||
"tag_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/point/value/batch`
|
||||
|
||||
批量写点。
|
||||
|
||||
请求头:
|
||||
|
||||
- `X-Write-Key: <key>`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"point_id": "uuid",
|
||||
"value": 12.3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"err_msg": null,
|
||||
"success_count": 1,
|
||||
"failed_count": 0,
|
||||
"results": [
|
||||
{
|
||||
"point_id": "uuid",
|
||||
"success": true,
|
||||
"err_msg": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tag
|
||||
|
||||
### GET `/api/tag`
|
||||
|
||||
分页获取标签列表。
|
||||
|
||||
查询参数:
|
||||
|
||||
- `page`
|
||||
- `page_size`
|
||||
|
||||
### GET `/api/tag/{tag_id}`
|
||||
|
||||
当前实现返回该标签下的点位列表。
|
||||
|
||||
### POST `/api/tag`
|
||||
|
||||
创建标签。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Area-A",
|
||||
"description": "Area A points",
|
||||
"point_ids": ["uuid1", "uuid2"]
|
||||
}
|
||||
```
|
||||
|
||||
### PUT `/api/tag/{tag_id}`
|
||||
|
||||
更新标签。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Area-A",
|
||||
"description": "Updated",
|
||||
"point_ids": ["uuid1", "uuid2"]
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE `/api/tag/{tag_id}`
|
||||
|
||||
删除标签。
|
||||
|
||||
成功响应:`204 No Content`
|
||||
|
||||
---
|
||||
|
||||
## Page
|
||||
|
||||
`page` 用于保存页面布局或组件映射数据。
|
||||
|
||||
### GET `/api/page`
|
||||
|
||||
查询页面列表。
|
||||
|
||||
查询参数:
|
||||
|
||||
- `name`:可选,按名称模糊搜索
|
||||
|
||||
### GET `/api/page/{page_id}`
|
||||
|
||||
获取单个页面。
|
||||
|
||||
### POST `/api/page`
|
||||
|
||||
创建页面。
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"data": {
|
||||
"widgetA": "uuid1",
|
||||
"widgetB": "uuid2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PUT `/api/page/{page_id}`
|
||||
|
||||
更新页面。
|
||||
|
||||
请求体字段均可选:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"data": {
|
||||
"widgetA": "uuid1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE `/api/page/{page_id}`
|
||||
|
||||
删除页面。
|
||||
|
||||
成功响应:`204 No Content`
|
||||
|
||||
---
|
||||
|
||||
## Log
|
||||
|
||||
### GET `/api/logs`
|
||||
|
||||
读取日志文件内容。
|
||||
|
||||
查询参数:
|
||||
|
||||
- `file`:可选,指定日志文件名,仅允许 `app.log*`
|
||||
- `cursor`:可选,从指定游标后读取
|
||||
- `tail_lines`:可选,默认 `200`
|
||||
- `max_bytes`:可选
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"file": "app.log",
|
||||
"cursor": 1024,
|
||||
"lines": ["..."],
|
||||
"truncated": false,
|
||||
"reset": false
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/logs/stream`
|
||||
|
||||
SSE 实时日志流。
|
||||
|
||||
事件类型:
|
||||
|
||||
- `log`
|
||||
- `error`
|
||||
|
||||
客户端可使用 `EventSource` 订阅。
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
## 连接地址
|
||||
|
||||
- 公共广播:`/ws/public`
|
||||
- 客户端专属:`/ws/client/{client_id}`
|
||||
|
||||
## 服务端主动消息
|
||||
|
||||
### `PointNewValue`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "PointNewValue",
|
||||
"data": {
|
||||
"protocol": "opcua",
|
||||
"source_id": "uuid",
|
||||
"point_id": "uuid",
|
||||
"client_handle": 1001,
|
||||
"scan_mode": "subscribe",
|
||||
"timestamp": "2026-03-20 10:05:00.000",
|
||||
"quality": "good",
|
||||
"value": 12.3,
|
||||
"value_type": "float",
|
||||
"value_text": "12.3",
|
||||
"old_value": 12.1,
|
||||
"old_timestamp": "2026-03-20 10:04:59.000",
|
||||
"value_changed": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `PointSetValueBatchResult`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "PointSetValueBatchResult",
|
||||
"data": {
|
||||
"success": true,
|
||||
"err_msg": null,
|
||||
"success_count": 1,
|
||||
"failed_count": 0,
|
||||
"results": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端发送消息
|
||||
|
||||
### 写权限认证
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "auth_write",
|
||||
"data": {
|
||||
"key": "your-write-key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 批量写点
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "point_set_value_batch",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"point_id": "uuid",
|
||||
"value": 12.3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 历史曲线接口当前使用内存缓存,服务重启后历史会清空。
|
||||
- 实时遥测与 WebSocket 推送是“最新值优先”的设计,在高压场景下允许丢弃部分中间消息。
|
||||
- `/api/tag/{tag_id}` 当前返回的是标签下点位,而不是标签自身详情。
|
||||
|
|
@ -3,3 +3,4 @@ pub mod point;
|
|||
pub mod tag;
|
||||
pub mod log;
|
||||
pub mod page;
|
||||
pub mod doc;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
use axum::{
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use crate::util::response::ApiErr;
|
||||
|
||||
pub async fn get_api_md() -> Result<impl IntoResponse, ApiErr> {
|
||||
let content = tokio::fs::read_to_string("API.md")
|
||||
.await
|
||||
.map_err(|err| {
|
||||
tracing::error!("Failed to read API.md: {}", err);
|
||||
ApiErr::NotFound("API.md not found".to_string(), None)
|
||||
})?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/markdown; charset=utf-8"),
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, headers, content))
|
||||
}
|
||||
|
|
@ -147,7 +147,8 @@ fn build_router(state: AppState) -> Router {
|
|||
.route("/api/page", get(handler::page::get_page_list).post(handler::page::create_page))
|
||||
.route("/api/page/{page_id}", get(handler::page::get_page).put(handler::page::update_page).delete(handler::page::delete_page))
|
||||
.route("/api/logs", get(handler::log::get_logs))
|
||||
.route("/api/logs/stream", get(handler::log::stream_logs));
|
||||
.route("/api/logs/stream", get(handler::log::stream_logs))
|
||||
.route("/api/docs/api-md", get(handler::doc::get_api_md));
|
||||
|
||||
Router::new()
|
||||
.merge(all_route)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PLC Control API.md</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css" />
|
||||
</head>
|
||||
<body class="doc-page">
|
||||
<header class="topbar">
|
||||
<div class="title">PLC Control</div>
|
||||
<div class="topbar-actions">
|
||||
<a class="link-button" href="/ui/">返回监控</a>
|
||||
<div class="status" id="docStatus">加载中...</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="doc-view">
|
||||
<section class="doc-card">
|
||||
<div class="doc-card-head">
|
||||
<h2>API.md</h2>
|
||||
</div>
|
||||
<div class="doc-body">
|
||||
<pre id="docContent">加载中...</pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/ui/api-md.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
const docStatus = document.getElementById('docStatus');
|
||||
const docContent = document.getElementById('docContent');
|
||||
|
||||
function setDocStatus(text) {
|
||||
docStatus.textContent = text;
|
||||
}
|
||||
|
||||
async function loadApiMarkdown() {
|
||||
try {
|
||||
setDocStatus('加载中...');
|
||||
const response = await fetch('/api/docs/api-md');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
docContent.textContent = text || 'API.md 为空';
|
||||
setDocStatus('已加载');
|
||||
} catch (error) {
|
||||
docContent.textContent = `加载 API.md 失败\n\n${error.message || 'unknown error'}`;
|
||||
setDocStatus('加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
loadApiMarkdown();
|
||||
230
web/app.js
230
web/app.js
|
|
@ -43,11 +43,221 @@ const chartCanvas = el('chartCanvas');
|
|||
const chartTitle = el('chartTitle');
|
||||
const chartSummary = el('chartSummary');
|
||||
const refreshChartBtn = el('refreshChart');
|
||||
const openApiDocBtn = el('openApiDoc');
|
||||
const apiDocDrawer = el('apiDocDrawer');
|
||||
const closeApiDocBtn = el('closeApiDoc');
|
||||
const apiDocContent = el('apiDocContent');
|
||||
const apiDocToc = el('apiDocToc');
|
||||
|
||||
let apiDocLoaded = false;
|
||||
|
||||
function setStatus(text) {
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text) {
|
||||
let html = escapeHtml(text);
|
||||
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
||||
return html;
|
||||
}
|
||||
|
||||
function slugifyHeading(text, used) {
|
||||
const base = String(text || '')
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'section';
|
||||
let slug = base;
|
||||
let index = 2;
|
||||
while (used.has(slug)) {
|
||||
slug = `${base}-${index}`;
|
||||
index += 1;
|
||||
}
|
||||
used.add(slug);
|
||||
return slug;
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown) {
|
||||
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks = [];
|
||||
const toc = [];
|
||||
const usedHeadingIds = new Set();
|
||||
let paragraph = [];
|
||||
let listType = null;
|
||||
let listItems = [];
|
||||
let codeFence = false;
|
||||
let codeLines = [];
|
||||
|
||||
function flushParagraph() {
|
||||
if (!paragraph.length) return;
|
||||
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(' '))}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
|
||||
function flushList() {
|
||||
if (!listItems.length) return;
|
||||
const tag = listType || 'ul';
|
||||
blocks.push(`<${tag}>${listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</${tag}>`);
|
||||
listItems = [];
|
||||
listType = null;
|
||||
}
|
||||
|
||||
function flushCodeFence() {
|
||||
if (!codeFence) return;
|
||||
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
||||
codeFence = false;
|
||||
codeLines = [];
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('```')) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
if (codeFence) {
|
||||
flushCodeFence();
|
||||
} else {
|
||||
codeFence = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (codeFence) {
|
||||
codeLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^---+$/.test(trimmed)) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
blocks.push('<hr>');
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = trimmed.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
const level = heading[1].length;
|
||||
const title = heading[2].trim();
|
||||
const id = slugifyHeading(title, usedHeadingIds);
|
||||
toc.push({ level, title, id });
|
||||
blocks.push(`<h${level} id="${id}">${renderInlineMarkdown(title)}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = trimmed.match(/^>\s?(.*)$/);
|
||||
if (quote) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
blocks.push(`<blockquote>${renderInlineMarkdown(quote[1])}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unordered = trimmed.match(/^[-*]\s+(.*)$/);
|
||||
if (unordered) {
|
||||
flushParagraph();
|
||||
if (listType && listType !== 'ul') flushList();
|
||||
listType = 'ul';
|
||||
listItems.push(unordered[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ordered = trimmed.match(/^\d+\.\s+(.*)$/);
|
||||
if (ordered) {
|
||||
flushParagraph();
|
||||
if (listType && listType !== 'ol') flushList();
|
||||
listType = 'ol';
|
||||
listItems.push(ordered[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
flushList();
|
||||
flushCodeFence();
|
||||
|
||||
return { html: blocks.join(''), toc };
|
||||
}
|
||||
|
||||
function renderApiDocToc(items) {
|
||||
if (!apiDocToc) return;
|
||||
if (!items.length) {
|
||||
apiDocToc.innerHTML = '<div class="muted">无目录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
apiDocToc.innerHTML = items
|
||||
.map((item) => `<a class="doc-toc-item level-${item.level}" href="#${item.id}">${escapeHtml(item.title)}</a>`)
|
||||
.join('');
|
||||
|
||||
apiDocToc.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const targetId = link.getAttribute('href').slice(1);
|
||||
const target = apiDocContent.querySelector(`#${CSS.escape(targetId)}`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadApiDoc() {
|
||||
apiDocContent.innerHTML = '<p class="muted">加载中...</p>';
|
||||
if (apiDocToc) {
|
||||
apiDocToc.innerHTML = '<div class="muted">加载中...</div>';
|
||||
}
|
||||
const response = await fetch('/api/docs/api-md');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const markdown = await response.text();
|
||||
const rendered = renderMarkdown(markdown);
|
||||
apiDocContent.innerHTML = rendered.html;
|
||||
renderApiDocToc(rendered.toc);
|
||||
apiDocLoaded = true;
|
||||
}
|
||||
|
||||
async function openApiDocDrawer() {
|
||||
apiDocDrawer.classList.remove('hidden');
|
||||
if (!apiDocLoaded) {
|
||||
try {
|
||||
await loadApiDoc();
|
||||
} catch (err) {
|
||||
apiDocContent.innerHTML = `<p style="color: var(--danger);">加载 API.md 失败</p><pre><code>${escapeHtml(err.message || 'unknown error')}</code></pre>`;
|
||||
if (apiDocToc) {
|
||||
apiDocToc.innerHTML = '<div class="muted">目录加载失败</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeApiDocDrawer() {
|
||||
apiDocDrawer.classList.add('hidden');
|
||||
}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
|
|
@ -691,6 +901,26 @@ openSourceFormBtn.addEventListener('click', () => {
|
|||
closeSourceModalBtn.addEventListener('click', () => {
|
||||
sourceModal.classList.add('hidden');
|
||||
});
|
||||
if (openApiDocBtn) {
|
||||
openApiDocBtn.addEventListener('click', () => {
|
||||
openApiDocDrawer().catch((err) => setStatus(err.message));
|
||||
});
|
||||
}
|
||||
if (closeApiDocBtn) {
|
||||
closeApiDocBtn.addEventListener('click', closeApiDocDrawer);
|
||||
}
|
||||
if (apiDocDrawer) {
|
||||
apiDocDrawer.addEventListener('click', (event) => {
|
||||
if (event.target === apiDocDrawer) {
|
||||
closeApiDocDrawer();
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (apiDocDrawer && event.key === 'Escape' && !apiDocDrawer.classList.contains('hidden')) {
|
||||
closeApiDocDrawer();
|
||||
}
|
||||
});
|
||||
refreshChartBtn.addEventListener('click', () => {
|
||||
loadPointHistory().catch((err) => setStatus(err.message));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PLC Control</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css" />
|
||||
<link rel="stylesheet" href="/ui/styles.css?v=20260320b" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title">PLC Control</div>
|
||||
<div class="topbar-actions">
|
||||
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||||
<div class="status" id="statusText">Ready</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
|
|
@ -111,6 +114,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/ui/app.js"></script>
|
||||
<div class="drawer-backdrop hidden" id="apiDocDrawer">
|
||||
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="apiDocTitle">
|
||||
<div class="drawer-head">
|
||||
<h3 id="apiDocTitle">API.md</h3>
|
||||
<button type="button" class="secondary" id="closeApiDoc">关闭</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<aside class="doc-toc">
|
||||
<div class="doc-toc-title">目录</div>
|
||||
<div class="doc-toc-list" id="apiDocToc">加载中...</div>
|
||||
</aside>
|
||||
<div class="markdown-doc" id="apiDocContent">加载中...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="/ui/app.js?v=20260320b"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
260
web/styles.css
260
web/styles.css
|
|
@ -53,6 +53,30 @@ body {
|
|||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: var(--bg);
|
||||
border-color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Grid Layout ────────────────────────────────── */
|
||||
|
||||
.grid {
|
||||
|
|
@ -542,6 +566,237 @@ button.danger:hover { background: var(--danger-hover); }
|
|||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-backdrop.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: min(760px, 88vw);
|
||||
height: 100vh;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -24px 0 48px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.drawer-head h3 {
|
||||
font-size: 15px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.doc-toc {
|
||||
border-right: 1px solid var(--border-light);
|
||||
background: var(--surface-2);
|
||||
overflow: auto;
|
||||
padding: 14px 12px 18px;
|
||||
}
|
||||
|
||||
.doc-toc-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.doc-toc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-toc-item {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.doc-toc-item:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.doc-toc-item.level-1 { padding-left: 6px; font-weight: 700; color: var(--text); }
|
||||
.doc-toc-item.level-2 { padding-left: 14px; }
|
||||
.doc-toc-item.level-3 { padding-left: 22px; }
|
||||
.doc-toc-item.level-4 { padding-left: 30px; }
|
||||
|
||||
.markdown-doc {
|
||||
overflow: auto;
|
||||
padding: 18px 20px 24px;
|
||||
}
|
||||
|
||||
.markdown-doc {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.markdown-doc > * + * {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.markdown-doc h1,
|
||||
.markdown-doc h2,
|
||||
.markdown-doc h3,
|
||||
.markdown-doc h4 {
|
||||
color: var(--text);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-doc h1 {
|
||||
font-size: 28px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdown-doc h2 {
|
||||
font-size: 22px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.markdown-doc h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.markdown-doc h4 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.markdown-doc p,
|
||||
.markdown-doc li {
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.markdown-doc ul,
|
||||
.markdown-doc ol {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.markdown-doc li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.markdown-doc code {
|
||||
padding: 1px 5px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-light);
|
||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.markdown-doc pre {
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.markdown-doc pre code {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-doc hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdown-doc blockquote {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.doc-page {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.doc-view {
|
||||
min-height: calc(100vh - var(--topbar-h));
|
||||
padding: 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.doc-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.doc-card-head h2 {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.doc-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.doc-body pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Scrollbar ────────────────────────────────────── */
|
||||
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
|
|
@ -562,4 +817,9 @@ button.danger:hover { background: var(--danger-hover); }
|
|||
.panel.top-right { min-height: 300px; }
|
||||
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
||||
.panel.bottom-right { grid-column: 1; grid-row: 4; min-height: 320px; }
|
||||
.drawer { width: 100vw; }
|
||||
.drawer-body { grid-template-columns: 1fr; }
|
||||
.doc-toc { border-right: none; border-bottom: 1px solid var(--border-light); max-height: 180px; }
|
||||
.doc-view { padding: 0; }
|
||||
.doc-card { border-left: none; border-right: none; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue