初步开发了新的pymsg,并且应用在了dnfs上面

This commit is contained in:
qing committed 2026-07-26 17:57:27 +08:00
1 parent 303d151fce
commit 8ec42df779
13 files changed
+1085 -1906

No files matched your search

+1 -1
View File
@@ -2,4 +2,4 @@
cd "$(dirname "$0")" || exit
source ./venv/bin/activate
#exec ./main.dist/main.bin
exec python3 main.py
exec python3 dbv2.py
+564
View File
@@ -0,0 +1,564 @@
"""
dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
替代 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
协议:每条消息为完整 JSON,新增 ``_route`` 字段对应原 HTTP 路由路径。
请求示例::
{
"_route": "execute",
"action": "select",
"table": "Users",
"where": {"age": {"gt": 20}},
"order_by": [{"column": "id", "desc": true}],
"limit": 10
}
{
"_route": "log/ingest",
"logs": [{"service": "waf", "level": "INFO", "message": "..."}]
}
支持的路由::
execute → 原 /execute
log/ingest → 原 /log/ingest
log/query → 原 /log/query
log/stats → 原 /log/stats
log/purge → 原 /log/purge
log/health → 原 /log/health
"""
import json
import os
import time
import traceback
from datetime import datetime, timezone, timedelta
from sqlalchemy import (
create_engine, MetaData, Table,
select, insert, update, delete, func, and_,
)
from sqlalchemy.exc import SQLAlchemyError
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
# ── 数据库配置 ──────────────────────────────────────────────────────────
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
# ── 日志模块常量 ────────────────────────────────────────────────────────
VALID_SERVICES = {
"waf", "gateway", "static", "dnfs", "db",
"ai_review", "captcha", "core",
}
VALID_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
MAX_BATCH = 100
MAX_MESSAGE_LEN = 8192
MAX_EXTRA_LEN = 4096
DEFAULT_RETENTION_DAYS = 30
DEFAULT_MAX_RECORDS = 1_000_000
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
# ═══════════════════════════════════════════════════════════════════════
# DBServer — 主服务类
# ═══════════════════════════════════════════════════════════════════════
class DBServer:
"""基于 pymsg 的数据库服务,通过 TCP 长连接收发 JSON。"""
def __init__(self, addr="127.0.0.1", port=8005):
self.addr = addr
self.port = port
self.engine = create_engine(DATABASE_URL)
self.metadata = MetaData()
self._server = MsgServer(addr, port)
# 日志模块内部状态
self._logs_table_cache = {}
# 路由表
self._routes = {
"execute": self._handle_execute,
"log/ingest": self._handle_log_ingest,
"log/query": self._handle_log_query,
"log/stats": self._handle_log_stats,
"log/purge": self._handle_log_purge,
"log/health": self._handle_log_health,
}
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}")
self._start_cleanup_thread()
self._server.start(self._dispatch)
def stop(self) -> None:
"""从另一线程停止服务。"""
self._server.stop()
# ── 消息调度 -------------------------------------------------------
def _dispatch(self, sock, addr, text):
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
try:
req = json.loads(text)
except json.JSONDecodeError as e:
self._reply(sock, False, error=f"JSON 解析失败: {e}")
return
route = req.pop("_route", "")
handler = self._routes.get(route)
if handler is None:
self._reply(sock, False, error=f"未知路由: {route}")
return
try:
result = handler(req, sock=sock)
self._reply(sock, True, data=result)
except Exception as e:
traceback.print_exc()
self._reply(sock, False, error=str(e))
def _reply(self, sock, success, data=None, error=None):
resp = {"success": success}
if data is not None:
resp["data"] = data
if error is not None:
resp["error"] = error
self._server.reply(sock, json.dumps(resp, ensure_ascii=False, default=str))
# ── 表反射 ---------------------------------------------------------
def _get_table(self, table_name):
if table_name not in self.metadata.tables:
Table(table_name, self.metadata, autoload_with=self.engine)
return self.metadata.tables[table_name]
def _build_where(self, table, conditions):
if not conditions:
return None
clauses = []
for col, val in conditions.items():
column = table.columns[col]
if isinstance(val, dict):
if "eq" in val:
clauses.append(column == val["eq"])
if "ne" in val:
clauses.append(column != val["ne"])
if "gt" in val:
clauses.append(column > val["gt"])
if "lt" in val:
clauses.append(column < val["lt"])
if "ge" in val:
clauses.append(column >= val["ge"])
if "le" in val:
clauses.append(column <= val["le"])
if "like" in val:
clauses.append(column.like(val["like"]))
if "in" in val:
clauses.append(column.in_(val["in"]))
else:
clauses.append(column == val)
return and_(*clauses)
# ═══════════════════════════════════════════════════════════════════
# 路由处理 — execute
# ═══════════════════════════════════════════════════════════════════
def _handle_execute(self, data, **kw):
action = data.get("action", "select")
table_name = data.get("table")
if not table_name:
raise ValueError("缺少 table 字段")
try:
table = self._get_table(table_name)
except Exception as e:
raise ValueError(f"表加载失败: {e}")
with self.engine.connect() as conn:
if action == "select":
return self._do_select(conn, table, data)
elif action == "insert":
return self._do_insert(conn, table, data)
elif action == "update":
return self._do_update(conn, table, data)
elif action == "delete":
return self._do_delete(conn, table, data)
else:
raise ValueError(f"不支持的操作: {action}")
def _do_select(self, conn, table, data):
columns = data.get("columns")
if columns:
cols = [table.columns[c] for c in columns]
else:
cols = [table]
stmt = select(*cols)
where_cond = self._build_where(table, data.get("where"))
if where_cond is not None:
stmt = stmt.where(where_cond)
order_by = data.get("order_by")
if order_by:
for item in order_by:
col = table.columns[item["column"]]
stmt = stmt.order_by(col.desc() if item.get("desc") else col.asc())
limit = data.get("limit")
if limit is not None:
stmt = stmt.limit(limit)
offset = data.get("offset")
if offset is not None:
stmt = stmt.offset(offset)
res = conn.execute(stmt)
return [dict(row._mapping) for row in res.fetchall()]
def _do_insert(self, conn, table, data):
values = data.get("values", {})
stmt = insert(table).values(**values)
res = conn.execute(stmt)
conn.commit()
return [{"lastrowid": res.lastrowid}] if hasattr(res, "lastrowid") else []
def _do_update(self, conn, table, data):
values = data.get("values", {})
where_cond = self._build_where(table, data.get("where"))
stmt = update(table).values(**values)
if where_cond is not None:
stmt = stmt.where(where_cond)
res = conn.execute(stmt)
conn.commit()
return [{"rowcount": res.rowcount}]
def _do_delete(self, conn, table, data):
where_cond = self._build_where(table, data.get("where"))
stmt = delete(table)
if where_cond is not None:
stmt = stmt.where(where_cond)
res = conn.execute(stmt)
conn.commit()
return [{"rowcount": res.rowcount}]
# ═══════════════════════════════════════════════════════════════════
# 路由处理 — log/*
# ═══════════════════════════════════════════════════════════════════
# ── 日志表管理 ────────────────────────────────────────────────────
def _ensure_logs_table(self):
if "Logs" in self._logs_table_cache:
return self._logs_table_cache["Logs"]
with self.engine.connect() as conn:
conn.exec_driver_sql("""
CREATE TABLE IF NOT EXISTS Logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
service TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
trace_id TEXT,
extra TEXT
)
""")
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
)
conn.commit()
table = Table("Logs", self.metadata, autoload_with=self.engine)
self._logs_table_cache["Logs"] = table
return table
# ── 内部日志(直接写库,无 HTTP)──────────────────────────────────
def _log_internal(self, service, level, message,
trace_id=None, extra=None):
try:
table = self._ensure_logs_table()
extra_json = json.dumps(extra, ensure_ascii=False) if extra else None
if len(message) > MAX_MESSAGE_LEN:
message = message[:MAX_MESSAGE_LEN]
if extra_json and len(extra_json) > MAX_EXTRA_LEN:
extra_json = extra_json[:MAX_EXTRA_LEN]
with self.engine.connect() as conn:
conn.execute(insert(table).values(
ts=datetime.now(timezone.utc).isoformat(),
service=service,
level=level,
message=message,
trace_id=trace_id,
extra=extra_json,
))
conn.commit()
except Exception:
pass
# ── log/ingest ────────────────────────────────────────────────────
def _handle_log_ingest(self, data, **kw):
logs = data.get("logs")
if not isinstance(logs, list) or not logs:
raise ValueError("logs 必须是非空数组")
if len(logs) > MAX_BATCH:
raise ValueError(f"单次最多 {MAX_BATCH} 条,当前 {len(logs)} 条")
table = self._ensure_logs_table()
now = datetime.now(timezone.utc).isoformat()
rows = []
for i, log in enumerate(logs):
if not isinstance(log, dict):
raise ValueError(f"第 {i} 条日志不是有效对象")
service = log.get("service")
level = log.get("level")
message = log.get("message")
if not service or service not in VALID_SERVICES:
raise ValueError(f"第 {i} 条: service 无效或缺失")
if not level or level not in VALID_LEVELS:
raise ValueError(f"第 {i} 条: level 无效或缺失")
if not message or not isinstance(message, str):
raise ValueError(f"第 {i} 条: message 缺失或非字符串")
if len(message) > MAX_MESSAGE_LEN:
message = message[:MAX_MESSAGE_LEN]
trace_id = log.get("trace_id")
extra = log.get("extra")
extra_json = None
if extra is not None:
extra_json = json.dumps(extra, ensure_ascii=False)
if len(extra_json) > MAX_EXTRA_LEN:
extra_json = extra_json[:MAX_EXTRA_LEN]
rows.append({
"ts": log.get("ts") or now,
"service": service,
"level": level,
"message": message,
"trace_id": trace_id,
"extra": extra_json,
})
with self.engine.connect() as conn:
conn.execute(insert(table), rows)
conn.commit()
return {"inserted": len(rows)}
# ── log/query ─────────────────────────────────────────────────────
def _handle_log_query(self, data, **kw):
table = self._ensure_logs_table()
clauses = []
svc = data.get("service")
if svc:
col = table.columns["service"]
clauses.append(col.in_(svc) if isinstance(svc, list) else col == svc)
lvl = data.get("level")
if lvl:
col = table.columns["level"]
clauses.append(col.in_(lvl) if isinstance(lvl, list) else col == lvl)
tid = data.get("trace_id")
if tid:
clauses.append(table.columns["trace_id"] == tid)
since = data.get("since")
until = data.get("until")
ts_col = table.columns["ts"]
if since:
clauses.append(ts_col >= since)
if until:
clauses.append(ts_col <= until)
keyword = data.get("keyword")
if keyword:
clauses.append(table.columns["message"].like(f"%{keyword}%"))
where_cond = and_(*clauses) if clauses else None
order_col_name = data.get("order_by", "ts")
if order_col_name not in ("id", "ts", "service", "level"):
order_col_name = "ts"
order_col = table.columns[order_col_name]
order_expr = order_col.desc() if data.get("desc", True) else order_col.asc()
limit = min(data.get("limit", 100), 1000)
offset = data.get("offset", 0)
with self.engine.connect() as conn:
count_stmt = select(func.count()).select_from(table)
if where_cond is not None:
count_stmt = count_stmt.where(where_cond)
total = conn.execute(count_stmt).scalar() or 0
stmt = select(table)
if where_cond is not None:
stmt = stmt.where(where_cond)
stmt = stmt.order_by(order_expr).limit(limit).offset(offset)
rows = conn.execute(stmt).fetchall()
logs = []
for row in rows:
d = dict(row._mapping)
if d.get("extra"):
try:
d["extra"] = json.loads(d["extra"])
except (json.JSONDecodeError, TypeError):
pass
logs.append(d)
return {"total": total, "logs": logs}
# ── log/stats ─────────────────────────────────────────────────────
def _handle_log_stats(self, data, **kw):
table = self._ensure_logs_table()
group_by = data.get("group_by", "service")
group_fields = [g.strip() for g in group_by.split(",") if g.strip()]
for g in group_fields:
if g not in ("service", "level"):
raise ValueError(f"group_by 仅支持 service / level,收到: {g}")
clauses = []
since = data.get("since")
until = data.get("until")
ts_col = table.columns["ts"]
if since:
clauses.append(ts_col >= since)
if until:
clauses.append(ts_col <= until)
where_cond = and_(*clauses) if clauses else None
with self.engine.connect() as conn:
cols = [table.columns[g] for g in group_fields] + [func.count().label("count")]
stmt = select(*cols)
if where_cond is not None:
stmt = stmt.where(where_cond)
stmt = stmt.group_by(*[table.columns[g] for g in group_fields])
rows = conn.execute(stmt).fetchall()
groups = [dict(row._mapping) for row in rows]
count_stmt = select(func.count()).select_from(table)
if where_cond is not None:
count_stmt = count_stmt.where(where_cond)
total = conn.execute(count_stmt).scalar() or 0
return {"groups": groups, "total": total}
# ── log/purge ─────────────────────────────────────────────────────
def _handle_log_purge(self, data, **kw):
before = data.get("before")
if not before:
raise ValueError("缺少 before 字段")
table = self._ensure_logs_table()
with self.engine.connect() as conn:
stmt = delete(table).where(table.columns["ts"] < before)
result = conn.execute(stmt)
conn.commit()
deleted = result.rowcount
self._log_internal("db", "INFO",
f"purge 清理 {deleted} 条 (before={before})")
return {"deleted": deleted}
# ── log/health ────────────────────────────────────────────────────
def _handle_log_health(self, data, **kw):
table = self._ensure_logs_table()
with self.engine.connect() as conn:
count = conn.execute(
select(func.count()).select_from(table)
).scalar() or 0
return {"status": "ok", "count": count}
# ── 后台自动清理线程 ──────────────────────────────────────────────
def _start_cleanup_thread(self):
def _loop():
while True:
time.sleep(DEFAULT_CLEANUP_INTERVAL)
try:
self._do_cleanup()
except Exception:
pass
import threading
t = threading.Thread(target=_loop, daemon=True, name="dbv2-cleanup")
t.start()
def _do_cleanup(self):
table = self._ensure_logs_table()
cutoff = (
datetime.now(timezone.utc) - timedelta(days=DEFAULT_RETENTION_DAYS)
).isoformat()
with self.engine.connect() as conn:
result = conn.execute(
delete(table).where(table.columns["ts"] < cutoff)
)
conn.commit()
expired_deleted = result.rowcount
count = conn.execute(
select(func.count()).select_from(table)
).scalar() or 0
trimmed = 0
if count > DEFAULT_MAX_RECORDS:
excess = count - DEFAULT_MAX_RECORDS
cutoff_row = conn.execute(
select(table.columns["ts"])
.order_by(table.columns["ts"].asc())
.limit(1).offset(DEFAULT_MAX_RECORDS)
).fetchone()
if cutoff_row:
trim_result = conn.execute(
delete(table).where(table.columns["ts"] < cutoff_row[0])
)
conn.commit()
trimmed = trim_result.rowcount
if expired_deleted or trimmed:
self._log_internal(
"db", "INFO",
f"自动清理: 过期删除 {expired_deleted} 条, 超量裁剪 {trimmed} 条"
)
# ═══════════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8005, help="监听端口")
args = parser.parse_args()
server = DBServer(args.addr, args.port)
try:
server.start()
except KeyboardInterrupt:
server.stop()
+1
View File
@@ -2,3 +2,4 @@ nuitka
flask
SQLAlchemy
waitress
../pymsg
+30 -44
View File
@@ -23,52 +23,38 @@ from waitress import serve
import os
import json
import base64
import socket as _socket
import urllib.request
import urllib.error
from typing import Dict, Any
from security_middleware import apply_security_headers
from log_client import logs
DB_SERVER_URL = 'http://127.0.0.1:8005/execute'
def call_db_service(url: str, payload: dict, timeout: int = 10) -> dict:
'''
使用标准库向数据库服务发送 JSON 请求并返回解析后的响应。
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import send as _pmsg_send, recv as _pmsg_recv # noqa: E402
Args:
url: 服务端点 URL (例如 'http://127.0.0.1:8005/execute')
payload: 要发送的 JSON 数据字典
timeout: 请求超时秒数
Returns:
dict: 服务返回的 JSON 响应
Raises:
urllib.error.URLError: 网络连接错误
urllib.error.HTTPError: HTTP 状态错误 (4xx, 5xx)
json.JSONDecodeError: 响应不是有效的 JSON
'''
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
url,
data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
"""通过 pymsg 短连接调用 dbv2 数据库服务。
自动注入 ``_route: "execute"``。
"""
payload = {**payload, "_route": "execute"}
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
raw = response.read().decode('utf-8')
return json.loads(raw)
except urllib.error.HTTPError as e:
# HTTP错误,尝试读取错误响应体
error_body = e.read().decode('utf-8', errors='replace')
raise urllib.error.HTTPError(
e.url, e.code, f'HTTP Error {e.code}: {error_body}', e.hdrs, e.fp
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect((_DBV2_ADDR, _DBV2_PORT))
_pmsg_send(sock, json.dumps(payload))
resp_text = _pmsg_recv(sock, timeout=timeout)
sock.close()
return json.loads(resp_text)
except Exception as e:
raise ConnectionError(
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
) from None
except urllib.error.URLError as e:
raise urllib.error.URLError(f'连接失败: {e.reason}') from None
REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review'
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
@@ -208,7 +194,7 @@ def add_note():
}
}
notes = call_db_service(DB_SERVER_URL, select_payload)
notes = _call_dbv2(select_payload)
# 不存在的父节点
if len( notes['data']) == 0:
@@ -234,7 +220,7 @@ def add_note():
}
}
result = call_db_service(DB_SERVER_URL, insert_payload)
result = _call_dbv2(insert_payload)
success = result['success']
@@ -265,7 +251,7 @@ def get_notes():
}
}
notes = call_db_service(DB_SERVER_URL, select_payload)
notes = _call_dbv2(select_payload)
result = notes['data']
@@ -325,7 +311,7 @@ def delete_note():
}
}
notes = call_db_service(DB_SERVER_URL, select_payload)
notes = _call_dbv2(select_payload)
# 有子节点的情况不可删除
if len(notes['data']) > 0:
@@ -342,7 +328,7 @@ def delete_note():
}
}
data = call_db_service(DB_SERVER_URL, select_payload)
data = _call_dbv2(select_payload)
notes = data['data']
if not info['user'] == 'admin':
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
@@ -356,7 +342,7 @@ def delete_note():
'where': {'id': note_id}
}
result = call_db_service(DB_SERVER_URL, delete_payload)
result = _call_dbv2(delete_payload)
result = result['success']
@@ -417,7 +403,7 @@ def update_note():
}
}
data = call_db_service(DB_SERVER_URL, select_payload)
data = _call_dbv2(select_payload)
notes = data['data']
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
@@ -455,7 +441,7 @@ def update_note():
'where': {'id': id}
}
result = call_db_service(DB_SERVER_URL, update_payload)
result = _call_dbv2(update_payload)
result = result['success']
-144
View File
@@ -1,144 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>树状笔记</title>
<!-- ​Font Awesome 6.0.0 是矢量图标库,提供 7,000+ 免费图标及 17,000+ 专业图标。 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- 外部CSS文件 -->
<link rel="stylesheet" href="static/css/style.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-sitemap"></i> 树状笔记</h1>
<div class="header-actions">
<button onclick="resetForm()" class="btn btn-primary"><i class="fas fa-plus"></i> 新增节点</button>
<button onclick="goBack()" class="btn btn-secondary"><i class="fas fa-arrow-left"></i> 返回上级</button>
</div>
</div>
<!-- 节点迁移模式提示栏 -->
<div id="move-mode-bar" class="move-mode-bar" style="display: none;">
<div class="move-mode-info">
<i class="fas fa-exchange-alt"></i>
正在迁移节点:<strong id="moving-node-title"></strong>
</div>
<div class="move-mode-actions">
<button onclick="confirmMoveHere()" class="btn btn-move-here"><i class="fas fa-arrow-down"></i> 移动到此</button>
<button onclick="cancelMove()" class="btn btn-cancel-move"><i class="fas fa-times"></i> 取消</button>
</div>
</div>
<div class="main-content">
<div class="form-section">
<!-- 表单标题 -->
<h3 class="form-title"><i class="fas fa-pen"></i> <span id="form-mode-text">新增节点</span></h3>
<input type="hidden" id="edit-id" value=""> <!-- 隐藏输入框,要编辑节点的id -->
<!-- 输入节点标题 -->
<div class="form-group">
<label for="node-title"><i class="fas fa-heading"></i> 标题</label>
<input type="text" id="node-title" class="form-control" placeholder="输入节点标题">
</div>
<!-- 输入节点内容 -->
<div class="form-group">
<label for="node-content"><i class="fas fa-file-alt"></i> 节点说明</label>
<textarea id="node-content" class="form-control" placeholder="输入节点的文字说明"></textarea>
</div>
<!-- 文件上传区域 -->
<div class="form-group">
<label><i class="fas fa-upload"></i> 文件上传</label>
<div class="file-upload-area" onclick="document.getElementById('file-upload').click()">
<i class="fas fa-cloud-upload-alt" style="font-size: 2em; color: #888; margin-bottom: 10px;"></i>
<p>点击或拖拽文件到此处上传</p>
<p style="font-size: 0.8em; color: #999;">文件将以编码形式保存,同一节点内数据不要超过1M</p>
<input type="file" id="file-upload" style="display: none;" onchange="handleFileUpload(this.files)"> <!-- 隐藏输入框 -->
</div>
<!-- 选择文件状态信息 -->
<div class="file-info" id="file-info">未选择文件</div>
<!-- 文件元信息显示 -->
<div id="file-meta-container" style="display: none;">
<div id="file-meta-info" class="file-meta-info">
<p><i class="fas fa-file"></i> 文件名:<span id="meta-filename"></span></p>
<p><i class="fas fa-hdd"></i> 文件大小:<span id="meta-filesize"></span></p>
<p><i class="fas fa-calendar-alt"></i> 上传时间:<span id="meta-uploadtime"></span></p>
</div>
</div>
<!-- 数据修复提示 -->
<div id="data-repair-hint" class="data-repair-hint" style="display: none;">
<i class="fas fa-exclamation-circle"></i>
<span>检测到文件元数据损坏。请尝试重新上传文件以修复此问题。</span>
</div>
<!-- 文件进度条 -->
<div class="file-progress">
<div class="file-progress-bar" id="file-progress-bar"></div>
</div>
</div>
<!-- 文件下载区域(仅在有文件时显示) -->
<div class="form-group" id="download-section" style="display: none;">
<label><i class="fas fa-download"></i> 文件操作</label>
<div style="display: flex; gap: 8px;">
<button onclick="decodeAndDownload(null, null, true)" class="btn btn-success">
<i class="fas fa-download"></i> 下载文件
</button>
<button onclick="removeFile()" class="btn btn-danger">
<i class="fas fa-trash"></i> 移除文件
</button>
</div>
</div>
<!-- 隐藏的Base64存储字段 - 用户永远不会看到 -->
<textarea id="hidden-base64" class="form-control hidden-field" placeholder="Base64编码内容(自动生成,隐藏显示)"></textarea>
<input type="hidden" id="file-metadata" value="">
<!-- 动作表单 -->
<div class="form-actions">
<button onclick="saveNode()" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
<button onclick="resetForm()" class="btn btn-secondary"><i class="fas fa-undo"></i> 重置</button>
</div>
</div>
<!-- 节点表单 -->
<div id="node-list" class="node-list">
<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div id="delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title"><i class="fas fa-exclamation-triangle"></i> 确认删除</h3>
<button class="modal-close" onclick="closeDeleteModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<p id="delete-message">确定删除该节点吗?</p>
<p id="delete-node-title-container" style="font-weight: bold; margin: 8px 0;"></p>
<p id="delete-file-hint" style="font-size:.85em;color:#e74c3c;display:none;"><i class="fas fa-exclamation-circle"></i> 此节点包含文件,删除后文件将无法恢复</p>
<p style="font-size:.85em;color:#777">此操作不可恢复</p>
</div>
<div class="modal-footer">
<button onclick="confirmDelete()" class="btn btn-danger"><i class="fas fa-trash"></i> 删除</button>
<button onclick="closeDeleteModal()" class="btn btn-secondary"><i class="fas fa-times"></i> 取消</button>
</div>
</div>
</div>
<div id="toast-container"></div>
<!-- 外部JS文件 -->
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>
+23 -18
View File
@@ -17,28 +17,32 @@
"""
import json
import os as _os
import sys as _sys
import socket as _socket
import threading
import queue
import time
import urllib.request
import urllib.error
import atexit
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
from pymsg import send as _pmsg_send, recv as _pmsg_recv # noqa: E402
# ── 配置 ──────────────────────────────────────────────────────────────
DB_LOG_URL = 'http://127.0.0.1:8005/log/ingest'
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
_BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # HTTP 请求超时(秒)
_TIMEOUT = 5 # socket 超时(秒)
class LogClient:
"""异步批量日志推送客户端。"""
def __init__(self, url=DB_LOG_URL, batch_size=_BATCH_SIZE,
def __init__(self, batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL):
self._url = url
self._batch_size = batch_size
self._flush_interval = flush_interval
self._queue = queue.Queue()
@@ -129,20 +133,21 @@ class LogClient:
self._send(batch)
def _send(self, batch):
"""发送一批日志到 db 服务。失败时静默丢弃。"""
data = json.dumps({'logs': batch}).encode('utf-8')
req = urllib.request.Request(
self._url, data=data,
headers={'Content-Type': 'application/json'},
method='POST'
)
"""通过 pymsg 短连接发送一批日志到 dbv2。失败时静默丢弃。"""
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
resp.read() # 消费响应体
except (urllib.error.URLError, urllib.error.HTTPError, OSError):
# fail-safe:日志服务不可用,静默丢弃,不影响业务
pass
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
sock.settimeout(_TIMEOUT)
sock.connect((_DBV2_ADDR, _DBV2_PORT))
payload = {"_route": "log/ingest", "logs": batch}
_pmsg_send(sock, json.dumps(payload))
# 消费应答
try:
_pmsg_recv(sock, timeout=_TIMEOUT)
except Exception:
pass
sock.close()
except Exception:
# fail-safe:日志服务不可用,静默丢弃,不影响业务
pass
def _shutdown(self):
+1
View File
@@ -4,3 +4,4 @@ flask-cors
flask_sqlalchemy
#gunicorn
waitress
../pymsg
-504
View File
@@ -1,504 +0,0 @@
* { /* 通配符选择器,表示匹配页面中所有 HTML 元素 */
margin:0;
padding:0;
box-sizing:border-box
}
body { /* 针对 <body> 元素的样式设置,主要用于定义页面的整体基础样式 */
font-family:system-ui,sans-serif; /* 页面的默认字体。system-ui 表示优先使用操作系统的默认系统字体 */
background:#f5f5f5; /* 设置页面的背景色为浅灰色(#f5f5f5),这是一种常见的浅色调背景,既能与内容形成柔和对比,又不会像纯白色(#fff)那样刺眼,提升长时间阅读的舒适度。 */
min-height:100vh; /* 最小高度为视口高度。即使页面内容较少(不足以占满整个屏幕),背景色也能铺满整个视口,避免底部出现空白区域,保证页面视觉完整性。 */
padding:10px /* 给 <body> 元素设置内边距为 10px,使页面内容与浏览器窗口边缘保持一定距离,避免内容紧贴屏幕边框,提升布局的呼吸感和美观度。 */
}
.container { /* 针对类名为 container 的元素设置的样式,通常通常用于包裹页面主要内容的容器 */
max-width:800px; /* 限制容器的最大宽度为 800px。作用是避免内容在大屏幕上过度拉伸,保证文字阅读的舒适行宽(过宽的文本行不利于阅读) */
margin:0 auto; /* 设置容器的上下外边距为 0,左右外边距为 auto(自动计算),这是让块级元素在父容器中水平居中的经典写法。 */
background:#fff; /* 设置容器的背景色为纯白色(#fff),与前面 <body> 设置的浅灰色(#f5f5f5)背景形成对比,突出容器内的内容区域。 */
border-radius:6px; /* 给容器添加 6px 的圆角边框,使容器边角从直角变为圆弧,增加视觉柔和度,提升设计感。 */
box-shadow:0 1px 3px #ddd /* 为容器添加轻微的阴影效果:水平偏移 0、垂直偏移 1px、模糊半径 3px,阴影颜色为浅灰色(#ddd)。 */
} /* 作用是让容器在页面中产生轻微的 "悬浮" 感,与背景区分开,增强页面的层次感。 */
.header { /* 定义类名为 .header 的元素样式,通常适用于网页的头部导航栏或标题区域 */
background:#888; /* 设置元素的背景色为深灰色(#888),这是一种中性深色,适合作为头部背景以突出内容。 */
color:#fff; /* 定义元素内文字的颜色为纯白色(#fff),与深灰色背景形成强烈对比,确保文字清晰可读 */
padding:12px; /* 为元素设置内边距为 12px,使头部内容(如标题、菜单等)与边框保持一定距离,避免内容紧贴边缘,提升视觉舒适度。 */
display:flex; /* 将元素设置为 Flex 布局容器,使其内部子元素可以使用 Flexbox 的特性进行灵活排列,这是现代网页布局中常用的高效方式。 */
justify-content:space-between; /* 控制 Flex 容器中子元素在水平方向(主轴) 的对齐方式:子元素会沿水平方向分布,两端的子元素分别靠向容器的左右边缘,中间的剩余空间均匀分配在子元素之间。 */
align-items:center /* 控制 Flex 容器中子元素在垂直方向(交叉轴) 的对齐方式:所有子元素会在垂直方向上居中对齐,确保不同高度的元素(如文字和图标)在视觉上保持水平一致。 */
}
.header h1 { /* 用于设置类名为 .header 内部的 <h1> 标题元素的样式 */
font-size:1.4em /* 定义该 <h1> 标题的字体大小为 1.4em。 其中 em 是相对单位,基于父元素的字体大小进行计算(1em = 父元素字体大小)。 */
}
.header-actions { /* 用于定义类名为 .header-actions 的元素样式,该元素通常是页面头部(.header)中存放操作按钮、图标等交互元素的容器 */
display:flex; /* 设置为 Flex 布局容器,使其内部的子元素(如按钮、图标)能够沿水平方向(默认主轴方向)排列,且可通过 Flex 特性灵活控制子元素的对齐和分布,适配头部区域的横向布局需求。 */
gap:6px /* 为 Flex 容器内的子元素之间设置 6px 的间距,包括水平和垂直方向(若子元素换行)。作用是避免子元素(如 “编辑”“删除” 按钮、功能图标)紧贴在一起,让布局更宽松、清晰,同时无需手动给每个子元素设置 margin,简化样式代码。 */
}
.main-content { /* 定义类名为 .main-content 的元素样式,通常该元素是页面中承载主要内容(如文章、列表、表单等)的容器 */
padding:15px /* 为 .main-content 元素设置内边距为 15px,即元素内部的内容与元素边框(或边缘)之间保持 15px 的距离。 */
}
.form-section { /* 用于定义类名为 .form-section 的元素样式,从命名和样式特征来看,这个类通常用于包裹表单相关内容的区域 */
background:#f5f5f5; /* 设置背景色为浅灰色(#f5f5f5),与周围可能的白色(#fff)内容区域形成轻微对比,从而视觉上区分出表单区域。 */
padding:15px; /* 为表单区域设置 15px 的内边距,让表单内的输入框、标签等元素与边框保持距离,避免内容拥挤,提升填写体验。 */
border-radius:4px; /* 给区域添加 4px 的圆角边框,使边角呈现柔和的圆弧效果,减少生硬感,增强视觉友好度。 */
border:1px dashed #ddd; /* 设置 1px 宽的虚线边框,颜色为浅灰色(#ddd)。虚线样式相比实线更显轻盈,既能勾勒出表单区域的边界,又不会过于抢眼,同时与浅灰色背景形成协调的视觉效果。 */
margin-bottom:15px /* 在表单区域底部设置 15px 的外边距,使该区域与下方的其他内容保持距离,避免不同内容区块紧贴在一起,增强页面布局的层次感。 */
}
.form-title { /* 用于定义类名为 .form-title 的元素样式,从命名来看,这通常是表单区域的标题样式设置 */
color:#555; /* 设置标题文字颜色为中灰色(#555),相比纯黑色(#000)更柔和,既保证可读性,又不会过于抢眼,适合作为区域标题的视觉表现 */
margin-bottom:12px; /* 在标题底部设置 12px 的外边距,使标题与下方的表单内容(如输入框、按钮等)保持距离,形成清晰的内容分隔,提升表单区域的层次感。 */
font-size:1.1em; /* 定义标题字体大小为 1.1em(相对单位),基于父元素字体大小计算(若父元素默认 16px,则约为 17.6px)。这个大小既比普通内容文字略大,突出标题属性,又不会过于夸张,保持整体协调。 */
display:flex; /* 将标题元素设置为 Flex 布局容器,使其内部可能包含的子元素(如图标和文字)能够横向排列。 */
align-items:center; /* 让 Flex 容器内的子元素(如标题前的图标和文字)在垂直方向上居中对齐,确保不同元素在视觉上保持水平一致。 */
gap:6px /* 为标题内的子元素(如图标和文字)之间设置 6px 的间距,避免元素紧贴,使布局更清晰。 */
}
.form-group { /* 用于定义类名为 .form-group 的元素样式,从命名来看,该类通常用于包裹表单中的一组相关元素(如一个输入框与其对应的标签) */
margin-bottom:12px /* 设置 .form-group 元素底部的外边距为 12px,即每个表单组与下方的其他表单组之间保持 12px 的距离 */
}
.form-group label { /* 用于设置 .form-group 容器内的 <label> 标签样式,主要针对表单中输入项的说明文字进行美化 */
display:block; /* 将 <label> 从默认的行内元素(inline)改为块级元素(block),使其独占一行,强制后续的输入框(如 <input>)另起一行显示,形成 “标签在上,输入框在下” 的经典表单布局,更符合用户阅读和填写习惯。 */
margin-bottom:4px; /* 在标签底部设置 4px 的外边距,使标签与下方的输入框保持微小距离,避免两者紧贴,提升视觉清晰度。 */
color:#555; /* 标签文字使用中灰色(#555),相比黑色更柔和,既保证可读性,又不会抢夺输入框的视觉焦点(表单中输入区域通常需要更突出)。 */
font-size:.9em /* 标签字体大小为 0.9em(相对单位),比默认文字略小(若父元素默认 16px,则约为 14.4px),通过尺寸差异进一步弱化标签的视觉权重,让用户注意力更易集中在输入区域。 */
}
.form-control { /* 这段这段这段 CSS 代码用于定义类名为 .form-control 的元素样式,这类元素通常通常是表单中的输入控件(如文本框、下拉框等) */
width:100%; /* 设置输入控件的宽度为父容器(通常是 .form-group)的 100%,使其填满满整个表单组的宽度,保证表单表单控件在水平方向上对齐统一,形成整齐的视觉效果。 */
padding:8px; /* 为控件控件内部设置 8px 的内边距,让输入的文字与边框保持一定距离,避免仅避免文字紧贴边框影响阅读,也增加了控件的可点击区域和视觉面积,提升操作体验。 */
border:1px solid #ddd; /* 给控件添加 1px 宽的实线边框,颜色为浅灰色(#ddd),既明确了控件的边界,又不会过于醒目,保持整体风格的简洁柔和。 */
border-radius:3px; /* 设置 3px 的圆角边框,使控件边角变得圆润,相比直角边框更亲和用户,与表单其他元素的圆角设计(如 .form-section 的 border-radius: 4px)形成呼应,保持持视觉一致性。 */
font-size:.9em /* 控件内文字大小为 0.9em(相对单位),与前面表单标签的字体大小保持一致,确保表单内文字尺寸的协调性,避免视觉混乱。 */
}
textarea.form-control { /* 针对同时具有 form-control 类的 <textarea> 元素(多行文本输入框)的样式补充,用于优化多行输入框的特定表现 */
resize:vertical; /* vertical 表示用户只能垂直(上下)拖动文本框边缘来改变其高度,而不能水平(左右)调整宽度。 */
min-height:120px; /* 设置文本框的最小高度为 120px,确保初始状态下有足够的可视区域供用户输入多行内容(如备注、描述等),避免默认状态下文本框过小影响使用。 */
line-height:1.5 /* 设置文本框内文字的行高为 1.5 倍字号,增加多行文本之间的垂直间距,使长文本更易阅读和编辑,提升可读性。 */
}
.form-actions { /* 用于定义类名为 .form-actions 的元素样式,从命名和样式来看,这个类通常用于包裹表单底部的操作按钮(如 “提交”“取消” 按钮) */
display:flex; /* 将 .form-actions 设为 Flex 布局容器,使内部的按钮元素能够沿水平方向排列,方便控制按钮间的间距和对齐方式。 */
gap:8px; /* 为容器内的按钮之间设置 8px 的间距,避免多个操作按钮紧贴在一起,使布局更清晰,操作区域更易区分。 */
justify-content:flex-end; /* 控制 Flex 容器内按钮在水平方向的对齐方式:将所有按钮靠右对齐。这是表单设计的常见做法,符合用户视觉习惯(操作按钮通常放在表单底部右侧)。 */
margin-top:15px /* 在操作按钮区域顶部设置 15px 的外边距,使按钮区域与上方的表单输入内容保持距离,形成明显的视觉分隔,让用户完成输入后能快速定位到操作按钮。 */
}
.node-list { /* 用于定义类名为 .node-list 的元素样式,从命名推测,该元素可能是用于展示一系列节点(如列表项、卡片、条目等)的容器 */
margin:15px 0 /* 设置 .node-list 元素的上下外边距为 15px,左右外边距为 0。 */
}
.node { /* 用于定义类名为 .node 的元素样式,从样式特征来看,这类元素通常是列表中的单个条目(如内容节点、信息卡片等) */
background:#fafafa; /* 设置节点的背景色为极浅的灰色。既能与周围区域形成微妙区分,又保持了整体的柔和基调*/
padding:15px; /* 为节点内部设置 15px 的内边距,让节点内的内容(如文本、图标等)与边框保持距离,避免拥挤,提升可读性 */
border-radius:4px; /* 给节点添加 4px 的圆角边框,使边角呈现柔和的圆弧效果,减少生硬感,与页面中其他元素(如表单区域)的圆角设计形成风格统一。 */
border-left:3px solid #888; /* 在节点左侧设置 3px 宽的深灰色(#888)实线边框,作为视觉标识。这种左侧边框设计常用于突出条目重要性或进行分类标记,能快速吸引用户注意力并形成视觉焦点。 */
box-shadow:0 1px 2px #eee; /* 为节点添加轻微的阴影效果(水平偏移 0、垂直偏移 1px、模糊半径 2px,颜色为极浅灰色 #eee),使节点产生轻微的悬浮感,增强与背景的层次感,但又不会过于抢眼。 */
margin-bottom:10px; /* 在节点底部设置 10px 的外边距,使列表中的多个节点之间保持适当距离,避免条目紧贴,保证列表的清晰可读性。 */
cursor:pointer /* 整张卡片可点击进入节点,显示手型光标 */
}
.node:hover { /* .node 元素的鼠标悬浮状态样式,用于在用户将鼠标指针移到节点条目上时,提供视觉反馈以增强交互体验 */
box-shadow:0 2px 4px #ddd /* 垂直偏移量从 1px 增加到 2px:阴影向下偏移更多,增强 “悬浮抬起” 的视觉感受;模糊半径从 2px 增加到 4px:阴影范围更扩散,让悬浮效果更明显; 阴影颜色从 #eee(极浅灰)加深到 #ddd(浅灰):阴影对比度提高,进一步强化视觉反馈。 */
}
/* 文件节点特殊样式 */
.node.has-file { /* 这是一个多类选择器,表示同时拥有 node 和 has-file 两个类的元素。 */
border-left-color: #2ecc71; /* 通过左侧边框的颜色差异,直观区分出 “包含文件的节点” 与普通节点,让用户能快速识别节点类型,提升信息扫描效率。 */
}
.node-title { /* 用于定义类名为 .node-title 的元素样式,从命名和样式来看,这通常是节点(.node)内部的标题部分 */
font-size:1.1em; /* 比节点内的普通文字略大,突出标题的层级地位,方便用户快速定位每个节点的核心信息 */
color:#333; /* 比普通内容文字的颜色更深,通过颜色对比差异强化标题的视觉权重,同时相比纯黑色(#000)更柔和,避免视觉刺眼 */
margin-bottom:8px; /* 使其与下方的节点内容(如描述文字、附加信息等)保持距离,形成清晰的内容层次 */
cursor:pointer; /* 当鼠标指针悬停在标题上时,光标会变成手型(指针样式),暗示该标题是可点击的(可能关联展开 / 折叠、跳转等交互功能),增强交互的直观性 */
display:flex; /* 使其内部元素(如图标和文字)能横向排列。 */
align-items:center; /* 让标题内的子元素(如图标和文字)在垂直方向上居中对齐,保证视觉上的整齐度。 */
gap:6px /* 避免元素紧贴,提升布局清晰度。 */
}
.node-content { /* 用于定义类名为 .node-content 的元素样式,从命名和样式来看,这通常是节点(.node)内部的正文内容区域 */
color:#666; /* 通过颜色差异区分内容层级,让标题更突出,同时保持内容的可读性 */
font-size:.9em; /* 进一步通过尺寸差异强化标题与内容的层级关系,使节点结构更清晰 */
margin-bottom:12px; /* 使其与节点内下方的其他元素(如操作按钮、附加信息等)保持距离,避免内容拥挤,提升整体布局的呼吸感 */
white-space:pre-wrap; /* 控制文本的换行和空格处理:保留文本中的换行符和空格,同时当内容超出容器宽度时自动换行。 */
line-height:1.5 /* 增加多行文本之间的垂直间距,使长文本更易阅读,减轻视觉疲劳,提升内容的可读性。 */
}
.file-indicator { /* 用于定义类名为 .file-indicator 的元素样式,从命名和样式来看,这通常是用于标识节点中包含文件的小标签或提示元素 */
display: inline-block; /* 将元素设置为行内块级元素,使其既可以像行内元素一样与其他内容(如文本)在同一行显示,又可以像块级元素一样设置宽高、内外边距等样式,适合作为小型标签使用 */
background: #e9f5e9; /* 与包含文件的节点左侧边框颜色(#2ecc71)相呼应,形成统一的视觉标识系统,直观关联 “文件” 这一特征。 */
padding: 3px 8px; /* 使标签文字与边框保持适当距离,既保证标签小巧紧凑,又避免文字过于拥挤。 */
border-radius: 3px; /* 使其外观更柔和,与节点整体的圆角设计风格统一。 */
font-size: 0.8em; /* 比节点内容文字更小,作为辅助信息不抢占主要内容的视觉焦点。 */
color: #27ae60; /* 与背景色形成柔和对比,保证文字清晰可读,同时与节点左侧的文件标识色属同一色系,强化 “文件” 相关的视觉关联 */
margin-top: 5px; /* 使其与上方的内容(如节点描述)保持微小距离,避免紧贴,提升布局清晰度。 */
}
.node-actions { /* 用于定义类名为 .node-actions 的元素样式,从命名和样式来看,该元素通常是节点(.node)内部的操作按钮容器(如 “编辑””删除””查看” 等按钮) */
display:flex; /* 使内部的操作按钮能够沿水平方向排列,方便控制按钮间的间距和对齐方式 */
gap:8px; /* 避免多个按钮紧贴在一起,使每个操作选项更易区分,提升点击准确性 */
justify-content:flex-end; /* 控制按钮在水平方向的对齐方式:将所有操作按钮靠右对齐。这是内容条目中操作区的常见布局,既不干扰左侧主要内容的阅读,又符合用户寻找操作按钮的视觉习惯(通常在条目右侧)。 */
cursor:default /* 操作区不跟随卡片的 pointer 光标,提示用户这里是按钮区 */
}
.btn { /* 定义了一个基础按钮(.btn)的通用样式,适用于页面中各类按钮元素,具体解析如下 */
padding:5px 10px; /* 使按钮保持紧凑但不拥挤的尺寸,既方便点击又不会占用过多空间。 */
border:none; /* 移除默认边框,避免浏览器自带的按钮边框样式干扰自定义设计,使按钮风格更统一 */
border-radius:3px; /* 让按钮边角呈现柔和效果,与页面中其他元素(如表单、节点)的圆角设计保持风格一致 */
cursor:pointer; /* 鼠标悬停时显示手型光标,明确提示该元素可点击,增强交互的直观性。 */
font-size:.85em; /* 比普通内容文字略小,保持按钮的紧凑感,同时保证文字清晰可读 */
display:flex; /* 使用 Flex 布局,使按钮内可能包含的图标和文字能够横向排列。 */
align-items:center; /* 让按钮内的图标和文字在垂直方向上居中对齐,确保视觉上的整齐度。 */
gap:3px /* 避免元素紧贴,提升按钮内部布局的清晰度。 */
}
.btn-primary { /* 用于强调页面中的重要操作按钮 */
background:#888; /* 与页面头部的背景色保持一致,形成视觉呼应,强化主要操作按钮的辨识度 */
color:#fff /* 与深灰色背景形成强烈对比,确保文字清晰可读,同时突出按钮的重要性 */
}
.btn-danger { /* 危险 / 警告类按钮样式,通常用于表示具有破坏性或重要警告性质的操作(如 “删除”“取消”“确认删除” 等) */
background:#e74c3c; /* 设置按钮背景色为鲜红色,这是国际通用的警告 / 危险色,能快速引起用户注意,暗示该操作可能具有不可逆的后果(如删除数据) */
color:#fff /* 文字颜色为纯白色,与鲜红色背景形成高对比度,确保文字清晰可读,同时强化视觉冲击力,进一步突出操作的特殊性。 */
}
.btn-secondary { /* 用于表示次要按钮(次要操作按钮),通常用于次要或辅助性的交互操作 */
background:#aaa; /* 按钮背景色为中灰色,比主要按钮更浅,视觉权重更低,通过颜色明暗差异直观体现操作的次要地位 */
color:#fff /* 文字仍为纯白色,与中灰色背景保持足够对比度以保证可读性,同时保持与其他按钮在文字样式上的一致性。 */
}
.btn-warning { /* 用于表示警告类按钮,通常用于需要用户注意但不及危险操作严重的场景(如 “修改”“覆盖” 等可能带来一定影响的操作) */
background:#f39c12; /* 按钮背景色为橙色,这是国际通用的警告色,视觉上比主要按钮(灰色)更醒目,但比危险按钮(红色)柔和,既提示用户关注操作,又不会造成过度紧张感 */
color:#fff /* 文字为纯白色,与橙色背景形成高对比度,确保文字清晰可读,同时保持与其他按钮在文字样式上的一致性。 */
}
.btn-success { /* 用于表示成功状态或正向操作的按钮(如 “保存成功”“提交完成”“确认通过” 等场景) */
background:#2ecc71; /* 按钮背景色为青绿色,这是国际通用的成功 / 正向操作色,视觉上给人积极、安全的感受,常用于标识操作成功或具有明确积极意义的交互(如 “确认”“完成” 等)。 */
color:#fff /* 文字为纯白色,与绿色背景形成高对比度,确保文字文字清晰可读,同时保持与其他按钮在文字样式上的一致性。 */
}
.empty-state, .loading { /* 这是一个群组选择器,同时对两个类应用相同的样式,避免代码重复,确保空状态和加载状态在基础视觉表现上保持一致。 */
text-align:center; /* 使内容(如提示文字、加载图标等)在容器中水平居中对齐,这种居中布局符合用户对状态提示的视觉预期,显得更规整。 */
padding:30px; /* 让状态提示区域与周围内容保持足够距离,形成独立的视觉区块,突出状态信息 */
color:#777 /* 文字颜色为中灰色,既保证可读性,又不会过于抢眼,适合作为辅助性的状态提示(不干扰页面核心内容) */
}
.empty-state i, .loading i { /* 群组选择器,同时作用于空状态和加载状态容器内的 <i> 标签(图标元素),确保两种状态下的图标样式统一。 */
font-size:2em; /* 将图标大小设置为 2em(相对单位),比普通文字大得多,使图标成为状态提示中的视觉焦点,通过醒目的图标快速传递状态信息(如空状态可能用文件夹或文件图标,加载状态可能用旋转图标) */
margin-bottom:10px /* 使其与下方的文字说明(如 “暂无数据”“加载中...”)保持距离,避免图标与文字紧贴,提升状态提示区域的布局清晰度 */
}
@keyframes spin { /* 声明一个名为 spin 的动画,后续可以通过 animation 属性将此动画应用到任意元素上 */
0% {/* 动画开始时(0% 进度),元素旋转角度为 0 度(初始状态) */
transform:rotate(0deg)
}
100% { /* 动画结束时(100% 进度),元素旋转角度为 360 度(完整旋转一周) */
transform:rotate(360deg)
}
}
.loading i { /* 选择器定位到类为 .loading 的容器内的 <i> 标签(通常是图标元素),专门针对加载状态的图标应用样式 */
animation:spin 1s linear infinite /* 这是动画属性的简写。 动画持续时间为 1 秒,即图标旋转一周需要 1 秒。 */
} /* 动画速度曲线为匀速,使旋转过程平滑无加速或减速。动画循环次数为无限,确保在加载状态持续期间,图标一直保持旋转 */
.toast { /* 定义了一个名为 .toast 的样式类,用于创建页面中的轻量级提示框(类似 “Toast 通知”),通常用于操作成功 / 失败、系统提示等场景 */
position:fixed; /* 采用固定定位,使提示框脱离文档流,位置相对于浏览器窗口固定,不会随页面滚动而移动,确保用户始终能看到提示。 */
top:10px; /* 定位在页面右上角,这是 Toast 通知的经典位置(不遮挡主要内容,又能快速被注意到) */
right:10px; /* ... */
padding:8px 12px; /* 内部上下内边距 8px、左右 12px,让提示文字与边框保持适当距离,避免拥挤。 */
border-radius:3px; /* 使提示框外观更柔和,符合现代 UI 设计风格。 */
color:#fff; /* 文字颜色为白色,通常会配合不同背景色(如成功用绿色、错误用红色)使用,保证文字清晰可读。 */
font-size:.85em; /* 文字大小略小于默认字体,作为辅助提示信息不抢占视觉焦点。 */
z-index:1000; /* 较高的层级值,确保提示框显示在其他元素上方,不会被页面内容遮挡。 */
transform:translateX(100%); /* 默认状态下,通过 X 轴平移(向右移动自身宽度的 100%)将提示框移出视口右侧,实现 “隐藏” 效果。 */
transition:transform .3s /* 为 transform 属性添加过渡动画,使提示框的显示 / 隐藏过程平滑无突兀感。 */
}
.toast.show { /* 这是一个多类选择器,仅作用于同时拥有 toast 和 show 两个类的元素。结合之前定义的 .toast 基础样式,这个类专门用于触发提示框的 “显示” 状态 */
transform:translateX(0) /* 将提示框的 X 轴偏移量设为 0,使其从右侧滑入并完全显示在视口内(右上角位置)由于设置了过渡时间,会以动画的形式完成 */
}
.toast.success { /* 它在 .toast 基础样式(定位、尺寸、动画等)的基础上,专门定制 “成功” 状态的视觉特征 */
background:#4caf50 /* 设置背景色为鲜亮的绿色,通过颜色直观传递 “操作成功” 的积极信息,与错误状态(通常用红色)、警告状态(通常用黄色)形成明确区分。 */
}
.toast.error { /* 它在 .toast 基础样式(固定定位、尺寸、圆角、文字颜色、过渡动画等)的基础上,专门定制 “错误” 状态的视觉特征 */
background:#e74c3c /* 这是经典的 “错误 / 危险色” */
}
.toast.info {
background:#2196f3 /* 这是经典的 “信息 / 通知色” */
}
.toast.warning {
background:#f39c12 /* 这是经典的 “警告 / 注意色” */
}
.modal {
display:none; /* 模态框默认处于隐藏状态,需要通过 JavaScript 动态修改为display: flex(或其他显示值)来触发显示。 */
position:fixed; /* 采用固定定位,使模态框脱离文档流,位置相对于浏览器窗口固定,不随页面滚动而移动,确保弹窗始终在视野内。 */
top:0;
left:0;
width:100%;
height:100%;
background:rgba(0,0,0,0.5); /* 设置背景为半透明黑色(透明度 50%),作为模态框的遮罩层,既模糊遮挡底层页面内容,又突出弹窗本身,增强视觉层次感。 */
z-index:999; /* 较高的层级值(999)确保模态框显示在页面其他元素之上,不会被底层内容遮挡(通常比普通元素层级高,但低于顶级提示如 Toast)。 */
justify-content:center; /* 让所有子元素在主轴方向上居中对齐 */
align-items:center; /* 让所有子元素在交叉轴方向上居中对齐。 */
}
.modal.show {
display:flex /* 将模态框的显示模式设置为 Flex 布局 */
}
.modal-content{ /* 模态框(Modal)内部的内容容器样式(.modal-content) */
background:#fff; /* 内容容器背景设为纯白色,与外部 .modal 的半透明黑色遮罩形成强烈对比,使弹窗内容从遮罩中凸显出来,提升可读性和视觉焦点 */
padding:15px; /* 让容器内的内容(如标题、文本、按钮)与边框保持适当距离,避免内容紧贴边缘导致的拥挤感,提升排版舒适度 */
border-radius:4px; /* 圆角设计,使内容容器的边角更柔和,与模态框的整体现代风格统一,避免直角带来的生硬感。 */
width:90%; /* 确保在手机等小屏幕设备上,弹窗不会超出屏幕边缘,保持良好的适配性。 */
max-width:500px /* 避免在桌面等大屏幕设备上弹窗过宽(过宽的内容会降低阅读体验),确保内容区域始终保持在舒适的阅读宽度范围内。 */
}
.modal-header { /* 用于组织模态框顶部区域的布局(通常包含标题和关闭按钮) */
display:flex; /* 使头部容器内的子元素(如标题、关闭按钮)可以灵活排列,便于实现两端对齐等布局效果 */
justify-content:space-between; /* 两端元素分别靠向容器的左右两端,中间自动留白。 */
margin-bottom:12px; /* 保持距离,避免视觉上的拥挤 */
padding-bottom:8px; /* 让头部内容(标题、按钮)与底部边框之间有一定空隙,提升内部排版的呼吸感 */
border-bottom:1px solid #eee /* 头部底部添加一条浅灰色的细边框,作为头部与内容区域的视觉分隔线,强化模态框内部的区域划分,让结构更清晰 */
}
.modal-title { /* 美化模态框头部的标题文字 */
font-size:1.1em; /* 既保证标题在视觉上的突出性(区别于正文内容),又不会因过大而显得突兀,符合 “标题略大于正文” 的排版逻辑 */
color:#555 /* 标题文字颜色为深灰色,既纯黑色更柔和,避免视觉刺眼,同时比浅灰色更清晰,确保标题的可读性。这种中性调既符合现代 UI 的简约风格,又能与模态框的白色背景形成适当对比,突出标题信息。 */
}
.modal-close { /* 定义了模态框关闭按钮的样式,用于美化和功能化模态框的关闭按钮(通常是 “×” 符号或 “关闭” 文字) */
background:none; /* 移除按钮默认的背景样式(如灰色背景或系统默认按钮背景),使按钮呈现 “透明背景” 效果,更贴合现代简约的 UI 设计,避免与模态框头部的白色背景产生视觉冲突。 */
border:none; /* 去除按钮默认的边框(如浏览器自带的按钮边框),进一步简化按钮外观,使其仅通过内容(如 “×” 符号)传递功能,不添加多余的视觉元素。 */
font-size:1.2em; /* 按钮文字 / 符号大小设置为 1.2em,比模态框标题略大,确保关闭按钮在视觉上足够显眼,用户能快速识别并点击。 */
cursor:pointer; /* 明确提示这是一个可点击的交互元素,增强用户操作的引导性。 */
color:#999 /* 按钮文字 / 符号颜色为浅灰色,既不会过于抢眼(避免分散对标题的注意力),又能清晰可见,符合其 “辅助功能按钮” 的定位 */
}
.modal-body { /* 用于包裹核心内容的容器类(如表单、文本、列表等主要信息) */
margin-bottom:15px /* 是与下方的元素保持距离,避免内容区域与操作区域紧贴在一起,形成清晰的视觉分隔,提升模态框内部布局的层次感和可读性。 */
}
.modal-footer { /* 用于布局模态框底部的操作按钮 */
display:flex; /* 使底部区域内的子元素(通常是按钮)能够灵活排列,便于控制按钮之间的间距和对齐方式 */
gap:8px; /* 确保多个按钮(如 “取消” 和 “确认”)之间有均匀的空隙,避免拥挤在一起,提升操作区域的整洁度和可读性。 */
justify-content:flex-end /* 控制 Flex 容器内子元素在主轴(默认水平方向)上的对齐方式:让所有子元素靠右对齐 */
}
.file-upload-area { /* 用于创建一个直观的 “拖放或点击上传文件” 的交互区域,常见于表单中的文件上传功能 */
border: 2px dashed #ccc; /* 虚线边框是文件上传区域的经典设计,通过视觉特征(非实线)暗示这是一个 “可交互的上传区域”,区别于普通容器。 */
padding: 20px; /* 为区域内的提示文字(如 “点击或拖放文件到此处”)、图标等内容预留足够空间,避免内容紧贴边框导致的拥挤感。 */
text-align: center; /* 区域内的文本和 inline 元素(如图标)水平居中对齐,确保提示信息在视觉上更规整,符合用户对 “上传区域” 的预期布局。 */
border-radius: 4px; /* 使边框边角更柔和,与整体 UI(如模态框的圆角风格)保持一致,避免直角带来的生硬感。 */
cursor: pointer; /* 鼠标悬停时显示 “手型光标”,明确提示该区域是可点击的交互元素,引导用户点击触发文件选择。 */
transition: border-color 0.3s; /* 为边框颜色变化添加 0.3 秒过渡动画,使状态变化(如鼠标悬停时边框变色)更平滑自然。通常会配合 :hover 伪类使用 */
margin-bottom: 10px; /* 使上传区域与下方元素(如文件预览区、提交按钮)保持距离,提升整体布局的层次感 */
}
.file-upload-area:hover { /* 示当鼠标指针悬停在元素上时,应用该样式。这是一种常见的交互反馈设计,通过视觉变化告知用户 “当前元素可交互”。 */
border-color: #888; /* 深灰色 */
}
.file-info { /* 主要用于显示文件上传相关的辅助信息(如文件名、大小、格式提示等) */
font-size: 0.9em; /* 比默认文字大小略小,明确其 “辅助信息” 的定位,避免与主要内容(如上传区域提示文字、按钮文本)争夺视觉焦点。 */
color: #666; /* 比正文文字颜色更柔和,既保证可读性,又通过颜色区分强调其 “补充说明” 的性质(如上传后的文件名、格式限制提示等)。 */
margin-top: 8px; /* 使文件信息区域与上方元素保持适当距离,避免信息堆叠,提升布局的呼吸感和清晰度。 */
}
.file-progress { /* 文件上传进度条容器的基础样式,用于展示文件上传过程中的进度状态 */
height: 5px; /* 用纤细的设计,既不占用过多视觉空间,又能清晰展示进度状态,符合常见进度条的紧凑设计风格 */
background-color: #eee; /* 作为进度条的 “底色”,当进度填充时,会被进度颜色覆盖,形成 “已完成进度” 与 “剩余进度” 的视觉对比。 */
border-radius: 3px; /* 与上传区域、模态框等组件的圆角风格统一,使界面元素风格协调,避免直角的生硬感。 */
overflow: hidden; /* 设置溢出隐藏,确保内部用于展示进度的子元素不会超出容器范围,保持进度条的整齐外观。 */
margin-top: 10px; /* 使进度条与上方元素保持距离,避免布局拥挤,提升信息层级的清晰度。 */
display: none; /* 默认隐藏进度条容器,因为进度条仅在文件上传过程中需要展示(未上传或上传完成后通常隐藏) */
}
.file-progress-bar { /* 定义了文件上传进度条的核心样式,用于直观展示文件上传的实时进度 */
height: 100%; /* 进度条高度设置为父容器的 100%,即填满父容器的 5px 高度,确保进度条在垂直方向上完全覆盖容器,形成完整的进度展示效果。 */
background-color: #4caf50; /* 与之前定义的 “成功状态”颜色一致,形成视觉风格统一,同时绿色传递 “正常进度”“成功” 的积极信号,让用户感知上传过程正常。 */
width: 0%; /* 初始宽度为 0%,表示上传未开始时进度条完全隐藏。实际使用时,会通过 JavaScript 根据上传进度动态修改该值(如从 0% 递增到 100%),直观反映文件上传的完成比例。 */
transition: width 0.3s; /* 为宽度变化添加 0.3 秒过渡动画,使进度条的宽度从一个值平滑过渡到另一个值(如从 30% 到 40%),避免进度跳变带来的突兀感,提升用户对进度变化的感知流畅度。 */
}
/* 文件元信息显示 */
.file-meta-info { /* 定义了文件元信息展示区域的样式,用于展示文件的详细元数据(如文件大小、类型、上传时间、修改日期等),通常在文件上传完成后显示 */
background: #e9f5e9; /* 低饱和度的 “成功色系”,与文件上传成功的状态形成视觉呼应,传递 “文件已处理完成” 的积极信号,同时浅色调不会过于抢眼。 */
padding: 8px; /* 为元信息内容(如文字、列表项)提供足够的呼吸空间,避免内容紧贴边框导致的拥挤感,提升可读性 */
border-radius: 3px; /* 与模态框、上传区域等组件的圆角风格统一,使界面元素风格协调,避免直角带来的生硬感。 */
border-left: 3px solid #2ecc71; /* 通过左侧的彩色色块色块与浅绿背景形成层次对比,突出元信息区域的边界; 深绿色与背景色同属绿色系,既协调又有区分度,强化 “成功 / 完成” 的状态暗示。 */
margin-top: 8px; /* 使元信息区域与上方元素(如进度条、文件信息提示)保持距离,避免信息堆叠,清晰划分不同功能区域(上传操作区→进度展示→元信息详情) */
font-size: 0.9em; /* 明确其 “辅助信息” 的定位,既保证内容可读,又不抢占主要操作区域的视觉焦点 */
}
.file-meta-info.error { /* 专门用于标识 “错误状态” 的文件元信息 */
background: #fdecea; /* 将背景色从基础样式的浅绿色改为浅红色。这种低饱和度的红色系背景,既与 “错误” 状态的视觉语言呼应,又不会过于刺眼,适合展示错误相关的辅助信息。 */
border-left-color: #e74c3c; /* 将左侧强调边框的颜色从基础样式的深绿色改为鲜红色。这个颜色与 .toast.error 中定义的错误状态主色完全一致,形成跨组件的视觉统一 —— 通过左侧醒目的红色边框,快速传递 “文件处理出错” 的状态,与成功状态的绿色边框形成鲜明对比,让用户一眼识别信息的性质。 */
}
.hidden-field { /* 定义了一个用于强制隐藏元素的类,主要用于需要彻底隐藏页面元素(如表单字段、临时内容等)的场景 */
display: none !important; /* 核心作用是将元素从页面布局中完全移除:元素不仅不可见,还不会占据任何空间(与 visibility: hidden 不同,后者仅隐藏元素但保留其占位空间)。
*/
} /* CSS 中的 “重要声明” 标记,用于强制提升该样式的优先级,确保它能覆盖其他可能影响该元素 display 属性的样式规则(即使其他规则的选择器权重更高)。 */
/* 下载按钮样式 */
.download-btn { /* 定义了下载按钮的样式,主要用于文件相关功能中(如下载已上传的文件),设计上突出了可交互性且保持与整体 UI 风格的一致性 */
margin-left: 8px; /* 使其与左侧相邻元素(如文件名、其他按钮)保持适当距离,避免元素紧贴,提升布局的呼吸感。 */
padding: 2px 6px; /* 小型按钮的紧凑设计,既保证按钮有足够的点击区域(符合交互体验),又不会占用过多空间,适合作为辅助性操作按钮(如在文件信息旁附加下载功能) */
font-size: 0.8em; /* 字体大小为 0.8em(小于默认文字大小),配合紧凑的内边距,强化 “小型辅助按钮” 的定位,避免与主要操作按钮(如 “确认”“上传”)争夺视觉焦点。 */
background-color: #2ecc71; /* 背景色采用深绿色,与之前定义的 “成功状态” 元素颜色一致,形成跨组件的视觉统一,同时绿色传递 “安全、可执行” 的积极信号,引导用户点击。 */
color: white; /* 确保按钮文字清晰可读,即使在小字号下也能轻松识别。 */
border-radius: 3px; /* 与模态框、文件元信息等组件的圆角风格统一,使按钮边角更柔和,避免直角带来的生硬感。 */
cursor: pointer; /* 鼠标悬停时显示 “手型光标”,明确提示这是可点击的交互元素,增强用户操作的引导性。 */
border: none; /* 移除按钮默认边框,使按钮外观更简洁,与绿色背景融为一体,符合现代 UI 的简约设计风格。 */
}
.download-btn:hover {
background-color: #27ae60; /* 深绿色 */
}
/* 数据修复提示 */
.data-repair-hint { /* 数据修复相关的提示信息样式,用于在数据修复过程中向用户展示提示、说明或状态信息(如修复进度、注意事项等) */
background: #fff3cd; /* 背景色为浅淡黄色,属于低饱和度的 “提示色系”。这种颜色既比白色背景更醒目(能吸引用户注意),又比红色(错误)、绿色(成功)更柔和,适合传递 “需要关注但非紧急 / 错误” 的信息,符合 “数据修复提示” 的功能定位 */
border-left: 3px solid #ffc107; /* 深黄色 */
padding: 8px; /* 为提示文字(如 “正在修复损坏的数据...”)预留空间,避免内容紧贴边框导致的拥挤感,提升可读性 */
margin: 10px 0; /* 使提示区域在垂直方向上与上下元素(如操作按钮、数据列表)保持距离,避免信息堆叠,清晰划分功能区域。 */
border-radius: 3px; /* 与模态框、文件元信息、按钮等组件的圆角风格统一,使界面元素风格协调,避免直角的生硬感。 */
font-size: 0.9em; /* 明确其 “辅助提示” 的定位,既保证内容可读,又不抢占主要操作区域的视觉焦点。 */
}
@media (max-width:600px){ /* 响应式媒体查询,用于在屏幕宽度小于或等于 600px(通常对应手机等移动设备)时,调整页面元素的样式,确保在小屏幕上有更好的显示效果和用户体验 */
.header{
flex-direction:column; /* 主轴方向设置为垂直方向(默认是水平方向)。这意味着头部内的子元素(如标题、导航项、操作按钮等)会从上到下依次排列,而非左右并排,适合需要垂直堆叠布局的头部设计(如移动端头部或紧凑布局的头部) */
gap:8px; /* 避免子元素紧贴在一起,让布局更透气、层次更清晰。 */
padding:10px /* 使头部内容(如文字、按钮)与容器边缘保持一定距离,避免内容贴边导致的拥挤感,同时提升视觉上的舒适感。 */
}
.header-actions{ /* 头部操作区的布局样式,主要用于控制头部区域中操作按钮 / 功能项的排列方式 */
width:100%; /* 使头部操作区的宽度占满其父容器的全部宽度,确保操作区在水平方向上充分利用可用空间,避免因宽度不足导致内部元素(如按钮、图标)挤压或换行 */
justify-content:center /* 该属性控制其内部子元素(如 “登录”“设置” 按钮、菜单图标等)在水平方向(主轴)上居中对齐 */
}
.form-actions{ /* 主要用于控制表单底部操作按钮(如 “提交”“取消”“重置” 等)的排列行为 */
flex-wrap:wrap /* 允许子元素(按钮)在容器宽度不足时自动换行。 */
}
.form-actions .btn{ /* 作用于所有包含在 .form-actions 容器内、类名为 btn 的按钮(即表单底部的 “提交”“取消” 等操作按钮) */
flex:1 /* 这是 Flex 布局的核心属性简写,等价于 flex: 1 1 0%,包含三个子属性 */
} /* flex-grow: 1:按钮可以根据父容器的剩余空间 “扩展”,扩展比例为 1(所有按钮的扩展比例相同) */
/* flex-shrink: 1:当父容器空间不足时,按钮可以 “收缩”,收缩比例为 1(所有按钮按相同比例收缩) */
/* flex-basis: 0%:按钮的初始宽度基准为 0,确保分配空间时完全基于 flex-grow 的比例,而非按钮自身内容的宽度 */
.modal-content{
width:95% /* 设置该容器的宽度为其父元素(通常是模态框的外层容器,如 .modal 或直接相对于 <body>)宽度的 95%。
这种百分比宽度的设计核心是响应式适配 */
}
textarea.form-control{
min-height:100px /* 最小高度为 100 像素 */
}
.move-mode-bar {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.move-mode-actions {
width: 100%;
justify-content: flex-start;
}
}
/* ===== 节点迁移功能样式 ===== */
.btn-move { /* 移动按钮:用于触发节点迁移模式 */
background: #3498db;
color: #fff;
}
.btn-move:hover {
background: #2980b9;
}
.move-mode-bar { /* 迁移模式提示栏:固定在页面顶部,显示正在迁移的节点信息和操作按钮 */
display: flex;
justify-content: space-between;
align-items: center;
background: #2c3e50;
color: #fff;
padding: 10px 15px;
gap: 10px;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.move-mode-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.move-mode-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-move-here { /* 移动到此按钮:确认将节点移到当前位置 */
background: #2ecc71;
color: #fff;
padding: 6px 14px;
font-size: 0.9em;
border: none;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-move-here:hover {
background: #27ae60;
}
.btn-cancel-move { /* 取消迁移按钮 */
background: transparent;
color: #ccc;
padding: 6px 10px;
font-size: 0.9em;
border: 1px solid #666;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.btn-cancel-move:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
File diff suppressed because it is too large. Load diff
-144
View File
@@ -1,144 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>树状笔记</title>
<!-- ​Font Awesome 6.0.0 是矢量图标库,提供 7,000+ 免费图标及 17,000+ 专业图标。 -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- 外部CSS文件 -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-sitemap"></i> 树状笔记</h1>
<div class="header-actions">
<button onclick="resetForm()" class="btn btn-primary"><i class="fas fa-plus"></i> 新增节点</button>
<button onclick="goBack()" class="btn btn-secondary"><i class="fas fa-arrow-left"></i> 返回上级</button>
</div>
</div>
<!-- 节点迁移模式提示栏 -->
<div id="move-mode-bar" class="move-mode-bar" style="display: none;">
<div class="move-mode-info">
<i class="fas fa-exchange-alt"></i>
正在迁移节点:<strong id="moving-node-title"></strong>
</div>
<div class="move-mode-actions">
<button onclick="confirmMoveHere()" class="btn btn-move-here"><i class="fas fa-arrow-down"></i> 移动到此</button>
<button onclick="cancelMove()" class="btn btn-cancel-move"><i class="fas fa-times"></i> 取消</button>
</div>
</div>
<div class="main-content">
<div class="form-section">
<!-- 表单标题 -->
<h3 class="form-title"><i class="fas fa-pen"></i> <span id="form-mode-text">新增节点</span></h3>
<input type="hidden" id="edit-id" value=""> <!-- 隐藏输入框,要编辑节点的id -->
<!-- 输入节点标题 -->
<div class="form-group">
<label for="node-title"><i class="fas fa-heading"></i> 标题</label>
<input type="text" id="node-title" class="form-control" placeholder="输入节点标题">
</div>
<!-- 输入节点内容 -->
<div class="form-group">
<label for="node-content"><i class="fas fa-file-alt"></i> 节点说明</label>
<textarea id="node-content" class="form-control" placeholder="输入节点的文字说明"></textarea>
</div>
<!-- 文件上传区域 -->
<div class="form-group">
<label><i class="fas fa-upload"></i> 文件上传</label>
<div class="file-upload-area" onclick="document.getElementById('file-upload').click()">
<i class="fas fa-cloud-upload-alt" style="font-size: 2em; color: #888; margin-bottom: 10px;"></i>
<p>点击或拖拽文件到此处上传</p>
<p style="font-size: 0.8em; color: #999;">文件将以编码形式保存,同一节点内数据不要超过1M</p>
<input type="file" id="file-upload" style="display: none;" onchange="handleFileUpload(this.files)"> <!-- 隐藏输入框 -->
</div>
<!-- 选择文件状态信息 -->
<div class="file-info" id="file-info">未选择文件</div>
<!-- 文件元信息显示 -->
<div id="file-meta-container" style="display: none;">
<div id="file-meta-info" class="file-meta-info">
<p><i class="fas fa-file"></i> 文件名:<span id="meta-filename"></span></p>
<p><i class="fas fa-hdd"></i> 文件大小:<span id="meta-filesize"></span></p>
<p><i class="fas fa-calendar-alt"></i> 上传时间:<span id="meta-uploadtime"></span></p>
</div>
</div>
<!-- 数据修复提示 -->
<div id="data-repair-hint" class="data-repair-hint" style="display: none;">
<i class="fas fa-exclamation-circle"></i>
<span>检测到文件元数据损坏。请尝试重新上传文件以修复此问题。</span>
</div>
<!-- 文件进度条 -->
<div class="file-progress">
<div class="file-progress-bar" id="file-progress-bar"></div>
</div>
</div>
<!-- 文件下载区域(仅在有文件时显示) -->
<div class="form-group" id="download-section" style="display: none;">
<label><i class="fas fa-download"></i> 文件操作</label>
<div style="display: flex; gap: 8px;">
<button onclick="decodeAndDownload(null, null, true)" class="btn btn-success">
<i class="fas fa-download"></i> 下载文件
</button>
<button onclick="removeFile()" class="btn btn-danger">
<i class="fas fa-trash"></i> 移除文件
</button>
</div>
</div>
<!-- 隐藏的Base64存储字段 - 用户永远不会看到 -->
<textarea id="hidden-base64" class="form-control hidden-field" placeholder="Base64编码内容(自动生成,隐藏显示)"></textarea>
<input type="hidden" id="file-metadata" value="">
<!-- 动作表单 -->
<div class="form-actions">
<button onclick="saveNode()" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
<button onclick="resetForm()" class="btn btn-secondary"><i class="fas fa-undo"></i> 重置</button>
</div>
</div>
<!-- 节点表单 -->
<div id="node-list" class="node-list">
<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div id="delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title"><i class="fas fa-exclamation-triangle"></i> 确认删除</h3>
<button class="modal-close" onclick="closeDeleteModal()"><i class="fas fa-times"></i></button>
</div>
<div class="modal-body">
<p id="delete-message">确定删除该节点吗?</p>
<p id="delete-node-title-container" style="font-weight: bold; margin: 8px 0;"></p>
<p id="delete-file-hint" style="font-size:.85em;color:#e74c3c;display:none;"><i class="fas fa-exclamation-circle"></i> 此节点包含文件,删除后文件将无法恢复</p>
<p style="font-size:.85em;color:#777">此操作不可恢复</p>
</div>
<div class="modal-footer">
<button onclick="confirmDelete()" class="btn btn-danger"><i class="fas fa-trash"></i> 删除</button>
<button onclick="closeDeleteModal()" class="btn btn-secondary"><i class="fas fa-times"></i> 取消</button>
</div>
</div>
</div>
<div id="toast-container"></div>
<!-- 外部JS文件 -->
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
#prjs
*.db
*.bin
*.build
*.dist
*.onefile-build
nuitka-crash-report.xml
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
+340
View File
@@ -0,0 +1,340 @@
"""pymsg - Length-prefixed variable-length socket message framing.
Protocol: [4-byte big-endian uint32 length][UTF-8 payload]
Usage:
import socket
from pymsg import send, recv
# sender
send(sock, "hello world")
# receiver
msg = recv(sock)
# with timeout (seconds)
send(sock, "hello", timeout=5.0)
msg = recv(sock, timeout=5.0)
"""
import contextlib
import select
import socket as _socket
import struct
__all__ = ["send", "recv", "TimeoutError", "MsgClient", "MsgServer"]
_HEADER = struct.Struct("!I") # 4 bytes, big-endian unsigned int
class TimeoutError(Exception):
"""Raised when a send or recv operation exceeds its timeout."""
@contextlib.contextmanager
def _timeout(sock: "_socket.socket", seconds: "float | None"):
"""Temporarily set the socket timeout, restoring it on exit."""
if seconds is None:
yield
return
original = sock.gettimeout()
sock.settimeout(seconds)
try:
yield
finally:
sock.settimeout(original)
def send(
sock: "_socket.socket",
data: str,
*,
timeout: "float | None" = None,
) -> None:
"""Send a string over a socket using length-prefixed framing.
Args:
sock: A connected socket object.
data: The string message to send.
timeout: Optional timeout in seconds. Raises TimeoutError on expiry.
"""
payload = data.encode("utf-8")
header = _HEADER.pack(len(payload))
with _timeout(sock, timeout):
try:
sock.sendall(header + payload)
except _socket.timeout:
raise TimeoutError(
f"send timed out after {timeout}s"
) from None
def recv(
sock: "_socket.socket",
*,
timeout: "float | None" = None,
) -> str:
"""Receive a length-prefixed string message from a socket.
Args:
sock: A connected socket object.
timeout: Optional timeout in seconds. Raises TimeoutError on expiry.
Returns:
The decoded string message.
Raises:
ConnectionError: If the connection is closed before a complete
message is received.
TimeoutError: If the operation exceeds the timeout.
"""
with _timeout(sock, timeout):
try:
header = _recv_exact(sock, _HEADER.size)
(length,) = _HEADER.unpack(header)
payload = _recv_exact(sock, length)
return payload.decode("utf-8")
except _socket.timeout:
raise TimeoutError(
f"recv timed out after {timeout}s"
) from None
def _recv_exact(sock: "_socket.socket", n: int) -> bytes:
"""Read exactly n bytes from sock, raising on premature close."""
buf = bytearray()
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError(
f"connection closed: expected {n} bytes, got {len(buf)}"
)
buf.extend(chunk)
return bytes(buf)
# ---------------------------------------------------------------------------
# 高层类:封装基于 pymsg 的客户端 / 服务器
# ---------------------------------------------------------------------------
class MsgClient:
"""TCP 聊天客户端:封装套接字创建、连接、发送、监听与关闭。
``listen()`` 在调用线程内阻塞运行,可另开线程调用 ``stop()`` 来终止。
Usage::
def on_msg(text: str) -> None:
print(f"收到: {text}")
c = MsgClient("localhost", 8003, "Alice")
c.connect()
c.send("hello") # 发送单条消息
c.listen(on_msg) # 阻塞当前线程,持续回调
"""
def __init__(self, addr: str, port: int, name: str) -> None:
self.addr = addr
self.port = port
self.name = name
self._sock: "_socket.socket | None" = None
self._running = False
# ---- 连接 ------------------------------------------------------------
def connect(self) -> None:
"""创建 TCP 套接字并连接到服务器。"""
self._sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
self._sock.connect((self.addr, self.port))
# ---- 发送单条消息(短连接)--------------------------------------------
def send(self, msg: str) -> None:
"""发送一条消息到服务器(使用短连接,pymsg 协议)。"""
try:
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s:
s.settimeout(3)
s.connect((self.addr, self.port))
send(s, f"{self.name}: {msg}")
except ConnectionRefusedError:
raise ConnectionRefusedError(
f"Could not connect to {self.addr}:{self.port}"
)
# ---- 持续监听(阻塞当前线程,回调模式)-------------------------------
def listen(self, on_message: "callable") -> None:
"""阻塞当前线程,持续接收服务器消息并回调。
Args:
on_message: 回调 ``on_message(text: str) -> None``。
"""
if self._sock is None:
raise RuntimeError("必须先调用 connect()")
self._running = True
socklist = [self._sock]
while self._running:
try:
readable, _, exception = select.select(
socklist, [], socklist, 0.1
)
if readable:
try:
text = recv(self._sock)
on_message(text)
except ConnectionError:
break
if exception:
break
except (ConnectionResetError, OSError):
break
# ---- 关闭 ------------------------------------------------------------
def stop(self) -> None:
"""设置停止标志并断开 TCP 连接。
可在另一个线程中调用,使 ``listen()`` 退出阻塞。
"""
self._running = False
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
class MsgServer:
"""基于 pymsg 长度前缀协议的多客户端 TCP 服务器。
收到消息时调用回调函数 —— 转发、过滤等逻辑全部在回调中实现。
Usage::
def on_msg(sender_sock, addr, text):
# 转发给所有人(排除发送者)
server.broadcast(f"[{addr}] {text}", except_sock=sender_sock)
server = MsgServer("0.0.0.0", 8003)
server.start(on_msg) # 阻塞当前线程
# 在另一线程中调用 server.stop() 可优雅退出
"""
def __init__(self, addr: str, port: int) -> None:
self.addr = addr
self.port = port
self._running = False
self._server_sock: "_socket.socket | None" = None
self._clients: "dict" = {} # socket -> (ip, port)
# ---- 启动(阻塞)----------------------------------------------------
def start(self, on_message: "callable") -> None:
"""启动服务器,阻塞当前线程。
Args:
on_message: 回调 ``on_message(sock, addr, text)``
- sock: 发送消息的客户端套接字
- addr: (ip, port) 元组
- text: 解码后的字符串消息
"""
self._running = True
servsock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
servsock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
servsock.bind((self.addr, self.port))
servsock.listen(10)
self._server_sock = servsock
socketlist = [servsock]
while self._running:
try:
readable, _, exception = select.select(
socketlist, [], socketlist, 0.1
)
except (ValueError, OSError):
break
for s in exception:
self._remove_client(s, socketlist)
for s in readable:
if s is servsock:
self._accept_client(servsock, socketlist)
else:
self._handle_client(s, socketlist, on_message)
self._shutdown(servsock, socketlist)
# ---- 广播 -----------------------------------------------------------
def broadcast(
self, msg: str, except_sock: "_socket.socket | None" = None
) -> None:
"""向所有已连接客户端广播消息(pymsg 协议)。
Args:
msg: 要发送的字符串。
except_sock: 排除的套接字(通常为发送者),不向其发送。
"""
disconnected = []
for client in list(self._clients):
if client is except_sock:
continue
try:
send(client, msg)
except (BrokenPipeError, ConnectionResetError, OSError):
disconnected.append(client)
for client in disconnected:
self._clients.pop(client, None)
try:
client.close()
except OSError:
pass
# ---- 应答 -----------------------------------------------------------
def reply(self, sock: "_socket.socket", msg: str) -> None:
"""向指定客户端套接字发送一条应答消息(pymsg 协议)。
用于请求-应答模式:服务器收到消息后,通过同一 socket 回复处理结果。
"""
send(sock, msg)
# ---- 停止 -----------------------------------------------------------
def stop(self) -> None:
"""设置停止标志并关闭监听套接字(可从另一线程调用)。"""
self._running = False
if self._server_sock is not None:
try:
self._server_sock.close()
except OSError:
pass
# ---- 内部方法 -------------------------------------------------------
def _accept_client(self, servsock, socketlist):
try:
client_sock, client_addr = servsock.accept()
if client_sock.fileno() != -1:
socketlist.append(client_sock)
self._clients[client_sock] = client_addr
except OSError:
pass
def _handle_client(self, s, socketlist, on_message):
try:
text = recv(s)
on_message(s, self._clients.get(s, ("unknown", 0)), text)
except (ConnectionError, ConnectionResetError, OSError):
self._remove_client(s, socketlist)
def _remove_client(self, s, socketlist):
self._clients.pop(s, None)
if s in socketlist:
socketlist.remove(s)
try:
s.close()
except OSError:
pass
def _shutdown(self, servsock, socketlist):
for s in socketlist:
try:
s.close()
except OSError:
pass
self._clients.clear()
+13
View File
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "pymsg"
version = "0.1.0"
description = "Length-prefixed variable-length socket message framing"
requires-python = ">=3.9"
license = "MIT"
[project.urls]
Repository = "https://github.com/user/micro_servers"