diff --git a/API.md b/API.md new file mode 100644 index 0000000..6dba9c8 --- /dev/null +++ b/API.md @@ -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: ` + +请求体: + +```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}` 当前返回的是标签下点位,而不是标签自身详情。 diff --git a/src/handler.rs b/src/handler.rs index 235a9b7..2bd23dc 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -3,3 +3,4 @@ pub mod point; pub mod tag; pub mod log; pub mod page; +pub mod doc; diff --git a/src/handler/doc.rs b/src/handler/doc.rs new file mode 100644 index 0000000..5c0ae93 --- /dev/null +++ b/src/handler/doc.rs @@ -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 { + 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)) +} diff --git a/src/main.rs b/src/main.rs index d3e0ba0..40f5e01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) diff --git a/web/api-md.html b/web/api-md.html new file mode 100644 index 0000000..f673ed4 --- /dev/null +++ b/web/api-md.html @@ -0,0 +1,31 @@ + + + + + + PLC Control API.md + + + +
+
PLC Control
+
+ 返回监控 +
加载中...
+
+
+ +
+
+
+

API.md

+
+
+
加载中...
+
+
+
+ + + + diff --git a/web/api-md.js b/web/api-md.js new file mode 100644 index 0000000..44fa273 --- /dev/null +++ b/web/api-md.js @@ -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(); diff --git a/web/app.js b/web/app.js index 89e29f6..0aabde3 100644 --- a/web/app.js +++ b/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, '$1'); + html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1'); + html = html.replace(/\*([^*\n]+)\*/g, '$1'); + 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(`

${renderInlineMarkdown(paragraph.join(' '))}

`); + paragraph = []; + } + + function flushList() { + if (!listItems.length) return; + const tag = listType || 'ul'; + blocks.push(`<${tag}>${listItems.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join('')}`); + listItems = []; + listType = null; + } + + function flushCodeFence() { + if (!codeFence) return; + blocks.push(`
    ${escapeHtml(codeLines.join('\n'))}
    `); + 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('
    '); + 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(`${renderInlineMarkdown(title)}`); + continue; + } + + const quote = trimmed.match(/^>\s?(.*)$/); + if (quote) { + flushParagraph(); + flushList(); + blocks.push(`
    ${renderInlineMarkdown(quote[1])}
    `); + 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 = '
    无目录
    '; + return; + } + + apiDocToc.innerHTML = items + .map((item) => `${escapeHtml(item.title)}`) + .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 = '

    加载中...

    '; + if (apiDocToc) { + apiDocToc.innerHTML = '
    加载中...
    '; + } + 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 = `

    加载 API.md 失败

    ${escapeHtml(err.message || 'unknown error')}
    `; + if (apiDocToc) { + apiDocToc.innerHTML = '
    目录加载失败
    '; + } + } + } +} + +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)); }); diff --git a/web/index.html b/web/index.html index 47d5ed0..b240a83 100644 --- a/web/index.html +++ b/web/index.html @@ -4,12 +4,15 @@ PLC Control - +
    PLC Control
    -
    Ready
    +
    + +
    Ready
    +
    @@ -111,6 +114,22 @@ - + + + diff --git a/web/styles.css b/web/styles.css index 789cdf3..9b1544a 100644 --- a/web/styles.css +++ b/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; } }