feat(web): add API.md drawer preview

This commit is contained in:
caoqianming 2026-03-20 19:00:46 +08:00
parent 920e37f759
commit a691f07e8e
9 changed files with 1183 additions and 4 deletions

590
API.md Normal file
View File

@ -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}` 当前返回的是标签下点位,而不是标签自身详情。

View File

@ -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;

23
src/handler/doc.rs Normal file
View File

@ -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))
}

View File

@ -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)

31
web/api-md.html Normal file
View File

@ -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>

24
web/api-md.js Normal file
View File

@ -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();

View File

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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));
}); });

View File

@ -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>

View File

@ -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; }
} }