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 tag;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod page;
|
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", 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/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", 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()
|
Router::new()
|
||||||
.merge(all_route)
|
.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 chartTitle = el('chartTitle');
|
||||||
const chartSummary = el('chartSummary');
|
const chartSummary = el('chartSummary');
|
||||||
const refreshChartBtn = el('refreshChart');
|
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) {
|
function setStatus(text) {
|
||||||
statusText.textContent = 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 = {}) {
|
async function apiFetch(url, options = {}) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -691,6 +901,26 @@ openSourceFormBtn.addEventListener('click', () => {
|
||||||
closeSourceModalBtn.addEventListener('click', () => {
|
closeSourceModalBtn.addEventListener('click', () => {
|
||||||
sourceModal.classList.add('hidden');
|
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', () => {
|
refreshChartBtn.addEventListener('click', () => {
|
||||||
loadPointHistory().catch((err) => setStatus(err.message));
|
loadPointHistory().catch((err) => setStatus(err.message));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>PLC Control</title>
|
<title>PLC Control</title>
|
||||||
<link rel="stylesheet" href="/ui/styles.css" />
|
<link rel="stylesheet" href="/ui/styles.css?v=20260320b" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="title">PLC Control</div>
|
<div class="title">PLC Control</div>
|
||||||
<div class="status" id="statusText">Ready</div>
|
<div class="topbar-actions">
|
||||||
|
<button type="button" class="secondary" id="openApiDoc">API.md</button>
|
||||||
|
<div class="status" id="statusText">Ready</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="grid">
|
<main class="grid">
|
||||||
|
|
@ -111,6 +114,22 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
260
web/styles.css
260
web/styles.css
|
|
@ -53,6 +53,30 @@ body {
|
||||||
color: var(--text-3);
|
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 Layout ────────────────────────────────── */
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
|
|
@ -542,6 +566,237 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
max-height: 50vh;
|
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 ────────────────────────────────────── */
|
/* ── Scrollbar ────────────────────────────────────── */
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
|
@ -562,4 +817,9 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
.panel.top-right { min-height: 300px; }
|
.panel.top-right { min-height: 300px; }
|
||||||
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
||||||
.panel.bottom-right { grid-column: 1; grid-row: 4; min-height: 320px; }
|
.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