add:初次提交

This commit is contained in:
2026-04-21 15:38:57 +08:00
commit 67f7a95fef
290 changed files with 5971 additions and 0 deletions

257
lj360/lj360.css Normal file
View File

@@ -0,0 +1,257 @@
/* Cockpit 风格样式 - 无内联样式 */
body.pf-m-redhat-font {
background-color: #f5f5f5;
margin: 0;
padding: 0;
font-family: 'RedHatText', 'Overpass', 'Segoe UI', Helvetica, Arial, sans-serif;
}
.cockpit-service-page {
padding: 24px 30px;
max-width: 1200px;
}
/* 卡片样式 */
.pf-c-card {
background: #ffffff;
border: 1px solid #e6e9ed;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.05);
margin-bottom: 25px;
}
.pf-c-card__header {
padding: 15px 20px;
border-bottom: 1px solid #e6e9ed;
background-color: #fafbfc;
font-weight: 600;
font-size: 16px;
display: flex;
align-items: center;
}
.pf-c-card__header i {
margin-right: 10px;
color: #0066cc;
}
.pf-c-card__body {
padding: 20px;
}
/* 状态面板 */
.status-panel {
background: #fafbfc;
border-left: 4px solid #73bcf7;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
margin-bottom: 20px;
border-radius: 2px;
}
.status-icon {
font-size: 18px;
}
.status-text {
font-weight: 500;
color: #151515;
}
/* Cockpit 风格开关 */
.cockpit-switch {
position: relative;
display: inline-block;
width: 48px;
height: 26px;
}
.cockpit-switch input {
opacity: 0;
width: 0;
height: 0;
}
.cockpit-switch .slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #b8bbbf;
transition: 0.2s;
border-radius: 34px;
}
.cockpit-switch .slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
.cockpit-switch input:checked + .slider {
background-color: #0066cc;
}
.cockpit-switch input:focus + .slider {
box-shadow: 0 0 1px #0066cc;
}
.cockpit-switch input:checked + .slider:before {
transform: translateX(22px);
}
/* 设置行 */
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #eaeef2;
margin-bottom: 16px;
}
.setting-label label {
font-weight: 600;
color: #151515;
font-size: 14px;
margin-bottom: 4px;
display: block;
}
.setting-description {
font-size: 12px;
color: #72767b;
margin-top: 2px;
}
.setting-control {
flex-shrink: 0;
}
/* 操作按钮组 */
.action-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin: 16px 0;
}
.pf-c-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border-radius: 3px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
background: none;
}
.pf-c-button i {
font-size: 13px;
}
.pf-c-button.pf-m-primary {
background-color: #0066cc;
color: white;
border-color: #0066cc;
}
.pf-c-button.pf-m-primary:hover {
background-color: #005cbb;
border-color: #005cbb;
}
.pf-c-button.pf-m-secondary {
background-color: #ffffff;
color: #0066cc;
border-color: #b8bbbf;
}
.pf-c-button.pf-m-secondary:hover {
background-color: #f5f5f5;
border-color: #0066cc;
}
/* 分隔线 */
.separator {
height: 1px;
background: #eaeef2;
margin: 20px 0 16px 0;
}
/* 单独测试区域 */
.testing-section {
margin: 8px 0 16px 0;
}
.testing-title {
font-size: 13px;
font-weight: 600;
color: #72767b;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.testing-title i {
color: #6ca100;
}
/* 输出区域 */
.output-section {
margin-top: 20px;
}
.output-label {
font-size: 13px;
font-weight: 600;
color: #4d5258;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.log-box {
background: #1e1e1e;
color: #d4d4d4;
padding: 14px;
border-radius: 4px;
font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
font-size: 12px;
height: 280px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid #3c3f41;
}
/* 状态颜色变化 */
.status-panel.status-success {
border-left-color: #3f9c35;
}
.status-panel.status-danger {
border-left-color: #c00;
}
.status-panel.status-warning {
border-left-color: #ec7a08;
}

84
lj360/lj360.html Normal file
View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh" class="index-page">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="../base1/patternfly.css" rel="stylesheet">
<link href="../base1/patternfly-additions.css" rel="stylesheet">
<link rel="stylesheet" href="lj360.css">
<script src="../base1/cockpit.js"></script>
</head>
<body class="pf-m-redhat-font">
<div class="cockpit-service-page">
<!-- 状态卡片 -->
<div class="pf-c-card">
<div class="pf-c-card__body">
<div class="status-panel" id="status-panel">
<div class="status-icon" id="status-icon">
<i class="fa fa-spinner fa-spin"></i>
</div>
<div class="status-text" id="status-text">正在获取状态...</div>
</div>
<!-- 开机自启动开关 - 使用 Cockpit 风格 -->
<div class="setting-row">
<div class="setting-label">
<label></label>
<div class="setting-description">系统启动时自动运行人员接近报警系统</div>
</div>
<div class="setting-control">
<label class="cockpit-switch">
<input type="checkbox" id="autostart-switch">
<span class="slider"></span>
</label>
</div>
</div>
<!-- 操作按钮组 -->
<div class="action-bar">
<button class="pf-c-button pf-m-primary" id="btn-start">
<i class="fa fa-play"></i> 启动服务
</button>
<button class="pf-c-button pf-m-secondary" id="btn-stop">
<i class="fa fa-stop"></i> 停止服务
</button>
<button class="pf-c-button pf-m-secondary" id="btn-restart">
<i class="fa fa-refresh"></i> 重启服务
</button>
<button class="pf-c-button pf-m-secondary" id="btn-status">
<i class="fa fa-search"></i> 刷新状态
</button>
<button class="pf-c-button pf-m-secondary" id="btn-log">
<i class="fa fa-file-text-o"></i> 查看日志
</button>
</div>
<!-- 单独测试区域 -->
<div class="separator"></div>
<div class="testing-section">
<div class="testing-title">
<i class="fa fa-flask"></i> 单独测试
</div>
<div class="action-bar">
<button class="pf-c-button pf-m-secondary" id="btn-pyon">
<i class="fa fa-play-circle-o"></i> 单次测试启动
</button>
<button class="pf-c-button pf-m-secondary" id="btn-pyoff">
<i class="fa fa-stop-circle-o"></i> 单次测试停止
</button>
</div>
</div>
<!-- 输出信息区域 -->
<div class="output-section">
<div class="output-label">
<i class="fa fa-terminal"></i> 输出信息
</div>
<div class="log-box" id="output">等待操作...</div>
</div>
</div>
</div>
</div>
<script src="lj360.js"></script>
</body>
</html>

233
lj360/lj360.js Normal file
View File

@@ -0,0 +1,233 @@
const SCRIPT = "/home/ztl/LJ360/bash/video_tool.sh";
const SERVICE = "lj360_camera";
const LOG_FILE = "/home/ztl/LJ360/lj360_camera_keepalive.log";
const WEBPY = "/home/ztl/LJ360/web.py";
// DOM 元素
let statusIcon, statusText, outputBox, autostartSwitch;
function initElements() {
statusIcon = document.getElementById("status-icon");
statusText = document.getElementById("status-text");
outputBox = document.getElementById("output");
autostartSwitch = document.getElementById("autostart-switch");
}
function setOutput(text) {
if (outputBox) {
outputBox.textContent = text;
outputBox.scrollTop = outputBox.scrollHeight;
}
}
function setStatus(text, isActive) {
if (statusText) {
statusText.textContent = text;
}
const panel = document.getElementById("status-panel");
if (panel) {
// 移除现有状态类
panel.classList.remove("status-success", "status-danger", "status-warning");
if (isActive === true) {
panel.classList.add("status-success");
} else if (isActive === false) {
panel.classList.add("status-danger");
} else if (isActive === undefined) {
panel.classList.add("status-warning");
}
}
if (statusIcon) {
if (isActive === true) {
statusIcon.innerHTML = '<i class="fa fa-check-circle" style="color:#3f9c35"></i>';
} else if (isActive === false) {
statusIcon.innerHTML = '<i class="fa fa-exclamation-circle" style="color:#c00"></i>';
} else {
statusIcon.innerHTML = '<i class="fa fa-refresh fa-spin"></i>';
}
}
}
function runCommand(args, callback) {
const proc = cockpit.spawn(args, { superuser: "require", err: "out" });
let output = "";
proc.stream(data => { output += data; });
proc.then(() => callback(output, null));
proc.catch(err => callback(output, err));
}
// 获取服务状态和开机自启状态
function refreshStatus() {
setStatus("正在获取服务状态...", undefined);
setOutput("⏳ 正在刷新状态...");
// 获取服务运行状态
runCommand(["systemctl", "is-active", SERVICE + ".service"], (outActive, errActive) => {
const isRunning = (outActive.trim() === "active");
// 获取开机自启状态
runCommand(["systemctl", "is-enabled", SERVICE + ".service"], (outEnabled, errEnabled) => {
let isEnabled = false;
if (!errEnabled) {
const enabledOut = outEnabled.trim();
isEnabled = (enabledOut === "enabled" || enabledOut === "static");
}
// 更新开关状态不触发change事件
if (autostartSwitch) {
autostartSwitch.checked = isEnabled;
}
// 更新状态文本
if (isRunning) {
setStatus("服务正在运行" + (isEnabled ? " (开机自启已启用)" : " (开机自启未启用)"), true);
} else {
setStatus("服务未运行" + (isEnabled ? " (开机自启已启用但未运行)" : " (开机自启未启用)"), false);
}
// 获取详细状态输出
runCommand(["systemctl", "status", SERVICE + ".service", "--no-pager", "-l"], (outDetail) => {
setOutput(outDetail || "无状态信息");
});
});
});
}
// 设置开机自启(通过开关)
function setAutostart(enabled) {
setOutput("⏳ " + (enabled ? "正在启用开机自启..." : "正在禁用开机自启..."));
const action = enabled ? "enable_autostart" : "disable_autostart";
runCommand(["bash", SCRIPT, action], (out, err) => {
if (err) {
setOutput("❌ 错误: " + (err.message || err) + "\n" + (out || ""));
// 恢复开关状态
setTimeout(() => refreshStatus(), 500);
} else {
setOutput((enabled ? "✅ 开机自启已启用" : "✅ 开机自启已禁用") + "\n" + (out || ""));
setTimeout(() => refreshStatus(), 300);
}
});
}
// 启动服务
function startService() {
setOutput("⏳ 正在启动服务...");
runCommand(["systemctl", "start", SERVICE + ".service"], (out, err) => {
if (err) {
setOutput("❌ 启动失败: " + (err.message || err) + "\n" + out);
} else {
setOutput("✅ 服务已启动\n" + out);
}
setTimeout(() => refreshStatus(), 500);
});
}
// 停止服务
function stopService() {
setOutput("⏳ 正在停止服务...");
runCommand(["systemctl", "stop", SERVICE + ".service"], (out, err) => {
if (err) {
setOutput("❌ 停止失败: " + (err.message || err) + "\n" + out);
} else {
setOutput("✅ 服务已停止\n" + out);
}
setTimeout(() => refreshStatus(), 500);
});
}
// 重启服务
function restartService() {
setOutput("⏳ 正在重启服务...");
runCommand(["systemctl", "restart", SERVICE + ".service"], (out, err) => {
if (err) {
setOutput("❌ 重启失败: " + (err.message || err) + "\n" + out);
} else {
setOutput("✅ 服务已重启\n" + out);
}
setTimeout(() => refreshStatus(), 800);
});
}
// 查看日志
function viewLog() {
setOutput("⏳ 读取日志文件...");
runCommand(["tail", "-n", "80", LOG_FILE], (out, err) => {
if (err) {
setOutput("❌ 无法读取日志: " + (err.message || err) + "\n尝试查看服务日志...");
// 备用:查看 journalctl
runCommand(["journalctl", "-u", SERVICE + ".service", "-n", "50", "--no-pager"], (out2, err2) => {
if (err2) {
setOutput("📭 日志为空或无法读取\n" + (out2 || ""));
} else {
setOutput("📋 系统日志 (journalctl):\n" + (out2 || "无日志"));
}
});
} else {
setOutput("📋 服务日志 (最近80行):\n" + (out || "日志为空"));
}
});
}
// 单独启动 web.py
function startWebPy() {
setOutput("⏳ 正在启动 web.py...");
runCommand([
"bash", "-c",
"export DISPLAY=:0 && cd /home/ztl/LJ360 && sudo -u ztl python3 /home/ztl/LJ360/web.py >> /home/ztl/LJ360/web.log 2>&1 &"
], (out, err) => {
if (err) {
setOutput("❌ 启动 web.py 失败: " + (err.message || err));
} else {
setOutput("✅ web.py 已在后台启动\n可通过 'ps aux | grep web.py' 查看进程");
}
});
}
// 停止 web.py
function stopWebPy() {
setOutput("⏳ 正在关闭 web.py...");
runCommand(["bash", "-c", "pkill -f /home/ztl/LJ360/web.py"], (out, err) => {
if (err) {
// pkill 没有找到进程也会返回错误,这是正常的
if (err.message && err.message.includes("exit code 1")) {
setOutput("⚠️ 未找到运行中的 web.py 进程");
} else {
setOutput("❌ 关闭失败: " + (err.message || err));
}
} else {
setOutput("✅ web.py 已关闭");
}
});
}
// 事件绑定
function bindEvents() {
const btnStart = document.getElementById("btn-start");
const btnStop = document.getElementById("btn-stop");
const btnRestart = document.getElementById("btn-restart");
const btnStatus = document.getElementById("btn-status");
const btnLog = document.getElementById("btn-log");
const btnPyon = document.getElementById("btn-pyon");
const btnPyoff = document.getElementById("btn-pyoff");
if (btnStart) btnStart.addEventListener("click", startService);
if (btnStop) btnStop.addEventListener("click", stopService);
if (btnRestart) btnRestart.addEventListener("click", restartService);
if (btnStatus) btnStatus.addEventListener("click", refreshStatus);
if (btnLog) btnLog.addEventListener("click", viewLog);
if (btnPyon) btnPyon.addEventListener("click", startWebPy);
if (btnPyoff) btnPyoff.addEventListener("click", stopWebPy);
// 开关事件:开机自启变更
if (autostartSwitch) {
autostartSwitch.addEventListener("change", function(e) {
setAutostart(e.target.checked);
});
}
}
// 页面初始化
document.addEventListener("DOMContentLoaded", function() {
initElements();
bindEvents();
refreshStatus();
});

420
lj360/lj360_config.css Normal file
View File

@@ -0,0 +1,420 @@
/* ===== 标签页 ===== */
.pf-c-tabs {
margin-bottom: 0;
border-bottom: 1px solid #d2d2d2;
background: #fff;
padding: 0 24px;
}
.pf-c-tabs__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 0;
}
.pf-c-tabs__item {
margin: 0;
}
.pf-c-tabs__link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 20px;
font-size: 14px;
font-weight: 500;
color: #6a6e73;
border: none;
border-bottom: 3px solid transparent;
background: none;
cursor: pointer;
transition: all 0.15s;
margin-bottom: -1px;
}
.pf-c-tabs__link:hover {
color: #0066cc;
background-color: #f5f5f5;
}
.pf-c-tabs__item.pf-m-current .pf-c-tabs__link {
color: #0066cc;
border-bottom-color: #0066cc;
font-weight: 600;
}
/* ===== 内容面板 ===== */
.tab-panel {
margin-top: 20px;
}
.tab-panel.hidden {
display: none;
}
/* ===== 区域标题 ===== */
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #151515;
margin-bottom: 14px;
}
.section-title i {
color: #0066cc;
}
.section-hint {
font-size: 12px;
font-weight: 400;
color: #72767b;
margin-left: 8px;
}
.warning-hint {
color: #ec7a08 !important;
}
.warning-hint i {
color: #ec7a08 !important;
}
/* ===== Logo 预览 ===== */
.logo-preview-container {
display: flex;
align-items: flex-start;
gap: 20px;
margin-bottom: 8px;
}
.logo-preview-box {
width: 320px;
height: 160px;
border: 2px dashed #d2d2d2;
border-radius: 6px;
background: #1e1e1e;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.logo-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #5a5a5a;
font-size: 12px;
}
.logo-preview-placeholder i {
font-size: 32px;
color: #444;
}
.logo-preview-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.logo-preview-actions {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
}
/* ===== 上传区域 ===== */
.upload-area {
border: 2px dashed #b8bbbf;
border-radius: 6px;
padding: 28px 20px;
text-align: center;
background: #fafbfc;
transition: all 0.2s;
cursor: pointer;
margin-bottom: 14px;
}
.upload-area:hover,
.upload-area.drag-over {
border-color: #0066cc;
background: #f0f7ff;
}
.upload-icon {
font-size: 36px;
color: #b8bbbf;
margin-bottom: 10px;
}
.upload-area:hover .upload-icon,
.upload-area.drag-over .upload-icon {
color: #0066cc;
}
.upload-text {
font-size: 14px;
color: #4d5258;
margin-bottom: 6px;
}
.upload-link {
color: #0066cc;
cursor: pointer;
text-decoration: underline;
font-weight: 500;
}
.upload-hint {
font-size: 12px;
color: #72767b;
}
.upload-status {
padding: 10px 14px;
border-radius: 4px;
font-size: 13px;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.upload-status.success {
background: #ecf7ec;
border: 1px solid #3f9c35;
color: #1e4620;
}
.upload-status.error {
background: #fff0ee;
border: 1px solid #c00;
color: #7d1d1d;
}
.upload-status.uploading {
background: #e7f1fa;
border: 1px solid #73bcf7;
color: #004080;
}
/* ===== Logo 文件列表 ===== */
.logo-list-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
font-weight: 600;
color: #4d5258;
margin-bottom: 10px;
}
.btn-sm {
padding: 5px 10px !important;
font-size: 12px !important;
}
.logo-list {
border: 1px solid #e6e9ed;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
min-height: 60px;
}
.logo-list-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px;
color: #72767b;
font-size: 13px;
}
.logo-list-empty i {
font-size: 18px;
}
.logo-list-item {
display: flex;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.1s;
gap: 12px;
}
.logo-list-item:last-child {
border-bottom: none;
}
.logo-list-item:hover {
background: #f5f9ff;
}
.logo-item-thumb {
width: 48px;
height: 28px;
background: #1e1e1e;
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.logo-item-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.logo-item-thumb-placeholder {
color: #555;
font-size: 10px;
}
.logo-item-info {
flex: 1;
min-width: 0;
}
.logo-item-name {
font-size: 13px;
font-weight: 500;
color: #151515;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.logo-item-meta {
font-size: 11px;
color: #72767b;
margin-top: 2px;
}
.logo-item-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* ===== 烧写区域 ===== */
.flash-area {
background: #fffbf0;
border: 1px solid #f5e6c8;
border-radius: 4px;
padding: 16px;
margin-bottom: 16px;
}
.flash-select-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.flash-label {
font-size: 13px;
font-weight: 500;
color: #4d5258;
white-space: nowrap;
}
.pf-c-form-control {
flex: 1;
min-width: 200px;
padding: 7px 10px;
border: 1px solid #b8bbbf;
border-radius: 3px;
font-size: 13px;
background: #fff;
color: #151515;
outline: none;
}
.pf-c-form-control:focus {
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
}
.flash-note {
font-size: 12px;
color: #72767b;
line-height: 1.6;
}
.flash-note code {
background: #f0f0f0;
padding: 1px 5px;
border-radius: 3px;
font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
color: #c00;
font-size: 11px;
}
/* 危险按钮 */
.pf-c-button.pf-m-danger {
background-color: #c00;
color: white;
border-color: #c00;
}
.pf-c-button.pf-m-danger:hover:not(:disabled) {
background-color: #a30000;
border-color: #a30000;
}
.pf-c-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ===== 空状态占位 ===== */
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #72767b;
text-align: center;
}
.empty-placeholder > i {
font-size: 48px;
color: #d2d2d2;
margin-bottom: 16px;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: #4d5258;
margin-bottom: 8px;
}
.empty-desc {
font-size: 13px;
color: #72767b;
}
/* ===== 通用 ===== */
.hidden {
display: none !important;
}

152
lj360/lj360_config.html Normal file
View File

@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="zh" class="index-page">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="../base1/patternfly.css" rel="stylesheet">
<link href="../base1/patternfly-additions.css" rel="stylesheet">
<link rel="stylesheet" href="lj360.css">
<link rel="stylesheet" href="lj360_config.css">
<script src="../base1/cockpit.js"></script>
</head>
<body class="pf-m-redhat-font">
<div class="cockpit-service-page">
<!-- 标签页导航 -->
<div class="pf-c-tabs" id="main-tabs">
<ul class="pf-c-tabs__list">
<li class="pf-c-tabs__item pf-m-current" data-tab="tab-logo">
<button class="pf-c-tabs__link">
<i class="fa fa-image"></i> 开机 Logo
</button>
</li>
<li class="pf-c-tabs__item" data-tab="tab-comm">
<button class="pf-c-tabs__link">
<i class="fa fa-plug"></i> 通讯参数
</button>
</li>
</ul>
</div>
<!-- ===== Tab: 开机 Logo ===== -->
<div class="tab-panel" id="tab-logo">
<div class="pf-c-card">
<div class="pf-c-card__header">
<i class="fa fa-image"></i> 开机 Logo 管理
</div>
<div class="pf-c-card__body">
<!-- 当前 Logo 预览 -->
<div class="section-title">
<i class="fa fa-eye"></i> 当前 Logo 预览
</div>
<div class="logo-preview-container">
<div class="logo-preview-box" id="logo-preview-box">
<div class="logo-preview-placeholder" id="logo-placeholder">
<i class="fa fa-picture-o"></i>
<span>点击"读取当前Logo"加载预览</span>
</div>
<img id="logo-preview-img" class="logo-preview-img hidden" alt="当前Logo预览">
</div>
<div class="logo-preview-actions">
<button class="pf-c-button pf-m-secondary" id="btn-read-logo">
<i class="fa fa-download"></i> 读取当前 Logo
</button>
</div>
</div>
<div class="separator"></div>
<!-- Logo 库 -->
<div class="section-title">
<i class="fa fa-folder-open-o"></i> Logo 库
<span class="section-hint">存储目录:/home/ztl/LJ360/config/logos/</span>
</div>
<!-- 上传新 Logo -->
<div class="upload-area" id="upload-area">
<div class="upload-icon">
<i class="fa fa-cloud-upload"></i>
</div>
<div class="upload-text">
拖拽 BMP 文件到此处,或
<label class="upload-link" for="logo-file-input">点击选择文件</label>
</div>
<div class="upload-hint">仅支持 .bmp 格式,建议分辨率与屏幕一致</div>
<input type="file" id="logo-file-input" accept=".bmp" class="hidden">
</div>
<!-- 上传状态 -->
<div id="upload-status" class="upload-status hidden"></div>
<!-- Logo 文件列表 -->
<div class="logo-list-header">
<span>已保存的 Logo 文件</span>
<button class="pf-c-button pf-m-secondary btn-sm" id="btn-refresh-list">
<i class="fa fa-refresh"></i> 刷新列表
</button>
</div>
<div class="logo-list" id="logo-list">
<div class="logo-list-empty">
<i class="fa fa-inbox"></i> 暂无 Logo 文件,请上传
</div>
</div>
<div class="separator"></div>
<!-- 烧写操作 -->
<div class="section-title">
<i class="fa fa-fire"></i> 烧写到设备
<span class="section-hint warning-hint">
<i class="fa fa-warning"></i> 此操作将直接写入 logo 分区,请谨慎操作
</span>
</div>
<div class="flash-area">
<div class="flash-select-row">
<div class="flash-label">选择要烧写的 Logo</div>
<select class="pf-c-form-control" id="flash-logo-select">
<option value="">-- 请先选择 Logo 文件 --</option>
</select>
<button class="pf-c-button pf-m-danger" id="btn-flash-logo" disabled>
<i class="fa fa-bolt"></i> 烧写到设备
</button>
</div>
<div class="flash-note">
<i class="fa fa-info-circle"></i>
烧写命令说明:将选定的 logo.bmp 与 logo_kernel.bmp 合并写入
<code>/dev/disk/by-partlabel/logo</code> 分区
</div>
</div>
<!-- 输出信息 -->
<div class="output-section">
<div class="output-label">
<i class="fa fa-terminal"></i> 操作输出
</div>
<div class="log-box" id="logo-output">等待操作...</div>
</div>
</div>
</div>
</div>
<!-- ===== Tab: 通讯参数 ===== -->
<div class="tab-panel hidden" id="tab-comm">
<div class="pf-c-card">
<div class="pf-c-card__header">
<i class="fa fa-plug"></i> 通讯参数配置
</div>
<div class="pf-c-card__body">
<div class="empty-placeholder">
<i class="fa fa-cogs"></i>
<div class="empty-title">暂未配置</div>
<div class="empty-desc">通讯参数配置功能正在开发中,敬请期待</div>
</div>
</div>
</div>
</div>
</div>
<script src="lj360_config.js"></script>
</body>
</html>

486
lj360/lj360_config.js Normal file
View File

@@ -0,0 +1,486 @@
// ===================== 常量配置 =====================
const CONFIG_DIR = "/home/ztl/LJ360/config";
const LOGO_DIR = CONFIG_DIR + "/logos";
const FLASH_SCRIPT = "/home/ztl/LJ360/bash/flash_logo.sh";
// ===================== 工具函数 =====================
function runCmd(args, callback) {
const proc = cockpit.spawn(args, { superuser: "require", err: "out" });
let output = "";
proc.stream(data => { output += data; });
proc.then(() => callback(output, null));
proc.catch(err => callback(output, err));
}
function setLogoOutput(text) {
const box = document.getElementById("logo-output");
if (box) {
box.textContent = text;
box.scrollTop = box.scrollHeight;
}
}
function appendLogoOutput(text) {
const box = document.getElementById("logo-output");
if (box) {
box.textContent += text;
box.scrollTop = box.scrollHeight;
}
}
function showUploadStatus(msg, type) {
const el = document.getElementById("upload-status");
if (!el) return;
el.textContent = msg;
el.className = "upload-status " + type;
el.classList.remove("hidden");
if (type === "success") {
setTimeout(() => el.classList.add("hidden"), 4000);
}
}
// ===================== 标签页切换 =====================
function initTabs() {
const tabItems = document.querySelectorAll(".pf-c-tabs__item");
tabItems.forEach(item => {
const btn = item.querySelector(".pf-c-tabs__link");
btn.addEventListener("click", () => {
// 切换激活样式
tabItems.forEach(i => i.classList.remove("pf-m-current"));
item.classList.add("pf-m-current");
// 切换内容面板
const targetId = item.getAttribute("data-tab");
document.querySelectorAll(".tab-panel").forEach(panel => {
panel.classList.add("hidden");
});
const target = document.getElementById(targetId);
if (target) target.classList.remove("hidden");
});
});
}
// ===================== 确保目录存在 =====================
function ensureDir(callback) {
runCmd(["mkdir", "-p", LOGO_DIR], (out, err) => {
if (err) {
setLogoOutput("❌ 无法创建配置目录: " + (err.message || err));
} else {
if (callback) callback();
}
});
}
// ===================== Logo 列表 =====================
let logoFiles = []; // 缓存当前文件列表
function refreshLogoList() {
const listEl = document.getElementById("logo-list");
const selectEl = document.getElementById("flash-logo-select");
listEl.innerHTML = '<div class="logo-list-empty"><i class="fa fa-spinner fa-spin"></i> 正在加载...</div>';
ensureDir(() => {
runCmd(["bash", "-c", `ls -lh "${LOGO_DIR}"/*.bmp 2>/dev/null || echo "__EMPTY__"`], (out, err) => {
logoFiles = [];
if (err || !out || out.trim() === "__EMPTY__" || out.trim() === "") {
listEl.innerHTML = '<div class="logo-list-empty"><i class="fa fa-inbox"></i> 暂无 Logo 文件,请上传</div>';
selectEl.innerHTML = '<option value="">-- 暂无可用文件 --</option>';
return;
}
// 解析 ls -lh 输出
const lines = out.trim().split("\n").filter(l => l && !l.startsWith("total"));
lines.forEach(line => {
// ls -lh 格式: -rw-r--r-- 1 root root 1.2M May 1 12:00 /path/to/file.bmp
const parts = line.trim().split(/\s+/);
if (parts.length >= 9) {
const filePath = parts.slice(8).join(" ");
const fileName = filePath.split("/").pop();
const fileSize = parts[4];
const fileDate = parts[5] + " " + parts[6] + " " + parts[7];
logoFiles.push({ path: filePath, name: fileName, size: fileSize, date: fileDate });
}
});
if (logoFiles.length === 0) {
listEl.innerHTML = '<div class="logo-list-empty"><i class="fa fa-inbox"></i> 暂无 Logo 文件,请上传</div>';
selectEl.innerHTML = '<option value="">-- 暂无可用文件 --</option>';
return;
}
// 渲染列表
listEl.innerHTML = "";
selectEl.innerHTML = '<option value="">-- 请选择 Logo 文件 --</option>';
logoFiles.forEach(file => {
// 列表项
const item = document.createElement("div");
item.className = "logo-list-item";
item.innerHTML = `
<div class="logo-item-thumb" id="thumb-${CSS.escape(file.name)}">
<span class="logo-item-thumb-placeholder"><i class="fa fa-image"></i></span>
</div>
<div class="logo-item-info">
<div class="logo-item-name" title="${file.name}">${file.name}</div>
<div class="logo-item-meta">${file.size} &nbsp;·&nbsp; ${file.date}</div>
</div>
<div class="logo-item-actions">
<button class="pf-c-button pf-m-secondary btn-sm btn-preview" data-path="${file.path}" data-name="${file.name}">
<i class="fa fa-eye"></i> 预览
</button>
<button class="pf-c-button pf-m-secondary btn-sm btn-select-flash" data-name="${file.name}">
<i class="fa fa-bolt"></i> 选为烧写
</button>
<button class="pf-c-button pf-m-secondary btn-sm btn-delete" data-path="${file.path}" data-name="${file.name}">
<i class="fa fa-trash-o"></i>
</button>
</div>
`;
listEl.appendChild(item);
// 下拉选项
const opt = document.createElement("option");
opt.value = file.name;
opt.textContent = file.name + " (" + file.size + ")";
selectEl.appendChild(opt);
});
// 绑定列表按钮事件
bindListItemEvents();
// 加载缩略图(读取文件转 base64 显示)
logoFiles.forEach(file => loadThumb(file));
});
});
}
function bindListItemEvents() {
// 预览按钮
document.querySelectorAll(".btn-preview").forEach(btn => {
btn.addEventListener("click", function() {
const path = this.getAttribute("data-path");
const name = this.getAttribute("data-name");
previewLogoFile(path, name);
});
});
// 选为烧写按钮
document.querySelectorAll(".btn-select-flash").forEach(btn => {
btn.addEventListener("click", function() {
const name = this.getAttribute("data-name");
const sel = document.getElementById("flash-logo-select");
if (sel) {
sel.value = name;
updateFlashButton();
// 滚动到烧写区域
sel.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
});
// 删除按钮
document.querySelectorAll(".btn-delete").forEach(btn => {
btn.addEventListener("click", function() {
const path = this.getAttribute("data-path");
const name = this.getAttribute("data-name");
if (confirm("确定要删除 " + name + " 吗?此操作不可恢复。")) {
deleteLogo(path);
}
});
});
}
// ===================== 缩略图加载 =====================
function loadThumb(file) {
// 使用 cockpit.file 读取 BMP 文件,转为 base64 显示
const fileHandle = cockpit.file(file.path, { binary: true });
fileHandle.read().then(content => {
fileHandle.close();
if (!content) return;
// 将 Uint8Array 转为 base64
const b64 = uint8ToBase64(content);
const dataUrl = "data:image/bmp;base64," + b64;
// 设置缩略图
const thumbEl = document.getElementById("thumb-" + CSS.escape(file.name));
if (thumbEl) {
thumbEl.innerHTML = `<img src="${dataUrl}" alt="${file.name}" style="max-width:100%;max-height:100%;object-fit:contain;">`;
}
}).catch(() => {
fileHandle.close();
});
}
// ===================== 预览 Logo =====================
function previewLogoFile(path, name) {
setLogoOutput("⏳ 正在读取 " + name + " ...");
const imgEl = document.getElementById("logo-preview-img");
const placeholder = document.getElementById("logo-placeholder");
const fileHandle = cockpit.file(path, { binary: true });
fileHandle.read().then(content => {
fileHandle.close();
if (!content || content.length === 0) {
setLogoOutput("❌ 文件内容为空或读取失败");
return;
}
const b64 = uint8ToBase64(content);
const dataUrl = "data:image/bmp;base64," + b64;
imgEl.src = dataUrl;
imgEl.classList.remove("hidden");
placeholder.classList.add("hidden");
setLogoOutput("✅ 已预览:" + name + "\n路径" + path + "大小:" + content.length + " 字节");
}).catch(err => {
fileHandle.close();
setLogoOutput("❌ 读取文件失败:" + (err.message || err));
});
}
// 读取当前设备 Logo从分区
function readCurrentLogo() {
setLogoOutput("⏳ 正在从设备分区读取 Logo...\n命令: dd if=/dev/disk/by-partlabel/logo of=/tmp/current_logo_preview.bmp bs=512 count=100 2>&1");
const imgEl = document.getElementById("logo-preview-img");
const placeholder = document.getElementById("logo-placeholder");
// 先用 dd 从分区读出前半段(第一个 BMP即 splash logo
runCmd(["bash", "-c",
"dd if=/dev/disk/by-partlabel/logo of=/tmp/current_logo_preview.bmp bs=512 2>/dev/null && echo OK"
], (out, err) => {
if (err && !out.includes("OK")) {
setLogoOutput("❌ 读取分区失败: " + (err.message || err) + "" + out +"提示:确认 /dev/disk/by-partlabel/logo 分区存在");
return;
}
// 读取保存的临时文件显示预览
const fileHandle = cockpit.file("/tmp/current_logo_preview.bmp", { binary: true });
fileHandle.read().then(content => {
fileHandle.close();
if (!content || content.length === 0) {
setLogoOutput("❌ 读取到的分区内容为空");
return;
}
const b64 = uint8ToBase64(content);
const dataUrl = "data:image/bmp;base64," + b64;
imgEl.src = dataUrl;
imgEl.classList.remove("hidden");
placeholder.classList.add("hidden");
setLogoOutput("✅ 已读取设备当前 Logo\n分区/dev/disk/by-partlabel/logo大小" + content.length + " 字节");
}).catch(err => {
fileHandle.close();
setLogoOutput("❌ 读取临时文件失败:" + (err.message || err));
});
});
}
// ===================== 上传 Logo =====================
function handleFileUpload(file) {
if (!file) return;
// 检查扩展名
if (!file.name.toLowerCase().endsWith(".bmp")) {
showUploadStatus("❌ 仅支持 .bmp 格式文件", "error");
return;
}
showUploadStatus("⏳ 正在上传 " + file.name + " ...", "uploading");
setLogoOutput("⏳ 正在上传文件:" + file.name);
const reader = new FileReader();
reader.onload = function(e) {
const arrayBuffer = e.target.result;
const uint8 = new Uint8Array(arrayBuffer);
const destPath = LOGO_DIR + "/" + file.name;
ensureDir(() => {
// 使用 cockpit.file 写入
const fileHandle = cockpit.file(destPath, { binary: true, superuser: "require" });
fileHandle.replace(uint8).then(() => {
fileHandle.close();
showUploadStatus("✅ 上传成功:" + file.name, "success");
setLogoOutput("✅ 文件已保存到:" + destPath + "大小:" + uint8.length + " 字节");
refreshLogoList();
}).catch(err => {
fileHandle.close();
showUploadStatus("❌ 上传失败:" + (err.message || err), "error");
setLogoOutput("❌ 写入文件失败:" + (err.message || err));
});
});
};
reader.onerror = function() {
showUploadStatus("❌ 文件读取失败", "error");
};
reader.readAsArrayBuffer(file);
}
// ===================== 删除 Logo =====================
function deleteLogo(path) {
setLogoOutput("⏳ 正在删除:" + path);
runCmd(["rm", "-f", path], (out, err) => {
if (err) {
setLogoOutput("❌ 删除失败:" + (err.message || err));
} else {
setLogoOutput("✅ 已删除:" + path);
refreshLogoList();
}
});
}
// ===================== 烧写 Logo =====================
function updateFlashButton() {
const sel = document.getElementById("flash-logo-select");
const btn = document.getElementById("btn-flash-logo");
if (sel && btn) {
btn.disabled = !sel.value;
}
}
function flashLogo() {
const sel = document.getElementById("flash-logo-select");
if (!sel || !sel.value) return;
const logoName = sel.value;
const logoPath = LOGO_DIR + "/" + logoName;
// 检查是否存在 logo_kernel.bmp
const kernelPath = LOGO_DIR + "/logo_kernel.bmp";
setLogoOutput("⏳ 准备烧写...\n主 Logo" + logoPath + "内核 Logo" + kernelPath);
// 检查文件是否存在
runCmd(["bash", "-c", `test -f "${logoPath}" && echo "logo_ok" || echo "logo_missing"`], (out1) => {
if (out1.trim() !== "logo_ok") {
setLogoOutput("❌ 找不到文件:" + logoPath);
return;
}
runCmd(["bash", "-c", `test -f "${kernelPath}" && echo "kernel_ok" || echo "kernel_missing"`], (out2) => {
const hasKernel = out2.trim() === "kernel_ok";
if (!hasKernel) {
setLogoOutput("⚠️ 未找到 logo_kernel.bmp" + kernelPath + "\n将仅烧写主 Logo跳过内核 Logo 合并步骤。\n\n⏳ 开始烧写...");
} else {
setLogoOutput("✅ 文件检查通过\n⏳ 开始合并并烧写...\n");
}
doFlash(logoPath, hasKernel ? kernelPath : null);
});
});
}
function doFlash(logoPath, kernelPath) {
// 构建烧写命令(与原始命令一致)
let cmd;
if (kernelPath) {
cmd = [
"bash", "-c",
`set -e && ` +
`cat "${logoPath}" > /tmp/logo && ` +
`truncate -s %512 /tmp/logo && ` +
`cat "${kernelPath}" >> /tmp/logo && ` +
`cat /tmp/logo > /dev/disk/by-partlabel/logo && ` +
`echo "FLASH_OK"`
];
} else {
cmd = [
"bash", "-c",
`set -e && ` +
`cat "${logoPath}" > /tmp/logo && ` +
`truncate -s %512 /tmp/logo && ` +
`cat /tmp/logo > /dev/disk/by-partlabel/logo && ` +
`echo "FLASH_OK"`
];
}
runCmd(cmd, (out, err) => {
if (err || !out.includes("FLASH_OK")) {
setLogoOutput(
"❌ 烧写失败!\n" +
"错误信息:" + (err ? (err.message || err) : "未知错误") + "" +
(out ? "\n输出\n" + out : "") +
"\n\n请检查\n" +
" 1. /dev/disk/by-partlabel/logo 分区是否存在" +
" 2. 是否有足够权限写入分区" +
" 3. BMP 文件格式是否正确"
);
} else {
setLogoOutput(
"✅ 烧写成功!\n" +
"主 Logo" + logoPath + "" +
(kernelPath ? "内核 Logo" + kernelPath + "" : "") +
"目标分区:/dev/disk/by-partlabel/logo\n" +
"重启设备后新 Logo 将生效。"
);
}
});
}
// ===================== 拖拽上传 =====================
function initDragDrop() {
const area = document.getElementById("upload-area");
if (!area) return;
area.addEventListener("dragover", e => {
e.preventDefault();
area.classList.add("drag-over");
});
area.addEventListener("dragleave", () => {
area.classList.remove("drag-over");
});
area.addEventListener("drop", e => {
e.preventDefault();
area.classList.remove("drag-over");
const file = e.dataTransfer.files[0];
if (file) handleFileUpload(file);
});
// 点击选择文件
const fileInput = document.getElementById("logo-file-input");
if (fileInput) {
fileInput.addEventListener("change", function() {
if (this.files[0]) handleFileUpload(this.files[0]);
this.value = ""; // 重置,允许重复选同一文件
});
}
}
// ===================== Base64 工具 =====================
function uint8ToBase64(uint8Array) {
let binary = "";
const chunkSize = 8192;
for (let i = 0; i < uint8Array.length; i += chunkSize) {
const chunk = uint8Array.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(null, chunk);
}
return btoa(binary);
}
// ===================== 页面初始化 =====================
document.addEventListener("DOMContentLoaded", function() {
initTabs();
initDragDrop();
refreshLogoList();
// 读取当前 Logo 按钮
const btnReadLogo = document.getElementById("btn-read-logo");
if (btnReadLogo) btnReadLogo.addEventListener("click", readCurrentLogo);
// 刷新列表按钮
const btnRefreshList = document.getElementById("btn-refresh-list");
if (btnRefreshList) btnRefreshList.addEventListener("click", refreshLogoList);
// 烧写按钮
const btnFlash = document.getElementById("btn-flash-logo");
if (btnFlash) btnFlash.addEventListener("click", flashLogo);
// 下拉选择变化
const flashSel = document.getElementById("flash-logo-select");
if (flashSel) flashSel.addEventListener("change", updateFlashButton);
});

16
lj360/manifest.json Normal file
View File

@@ -0,0 +1,16 @@
{
"main": "lj360.html",
"menu": {
"lj360-service": {
"label": "启动管理",
"path": "lj360.html",
"order": 10
},
"lj360-config": {
"label": "参数管理",
"path": "lj360_config.html",
"order": 20
}
}
}