<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>梨园 YOLO 识别演示</title>
    <link rel="stylesheet" href="/css/app.css?v=1775365302279">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
    <style>
        .page-overlay {
            position: fixed;
            inset: 0;
            z-index: 9999;
            display: flex;
            align-items: center;
            justify-content: center;
            background: rgba(0, 0, 0, 0.35);
            backdrop-filter: blur(6px);
            -webkit-backdrop-filter: blur(6px);
            opacity: 1;
            transition: opacity 240ms ease;
        }

        .page-overlay.is-hidden {
            opacity: 0;
            pointer-events: none;
        }

        .spinner {
            width: 42px;
            height: 42px;
            border-radius: 999px;
            border: 4px solid rgba(255, 255, 255, 0.35);
            border-top-color: rgba(255, 255, 255, 0.95);
            animation: spin 900ms linear infinite;
        }

        @keyframes spin {
            from { transform: rotate(0deg); }
            to { transform: rotate(360deg); }
        }

        .content {
            opacity: 0;
            transform: translateY(6px);
            transition: opacity 260ms ease, transform 260ms ease;
        }

        .content.is-ready {
            opacity: 1;
            transform: translateY(0);
        }

        .content.is-leaving {
            opacity: 0;
            transform: translateY(6px);
        }

        .panel {
            display: none;
        }

        .panel.is-active {
            display: block;
            animation: panelIn 180ms ease both;
        }

        @keyframes panelIn {
            from { opacity: 0; transform: translateY(6px); }
            to { opacity: 1; transform: translateY(0); }
        }

        .span-all {
            grid-column: 1 / -1;
        }

        .collapsible-card .card-header {
            width: 100%;
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
            border: 0;
            background: transparent;
            padding: 0 10px 0 0;
            text-align: left;
            cursor: pointer;
            color: inherit;
            font: inherit;
        }

        .collapsible-card .card-chevron {
            flex: 0 0 auto;
            opacity: 0.75;
            transition: transform 0.15s ease;
            padding-right: 6px;
        }

        .collapsible-card.is-collapsed .card-chevron {
            transform: rotate(-90deg);
        }

        .collapsible-card .card-body {
            overflow: hidden;
            max-height: 2000px;
            opacity: 1;
            transition: max-height 220ms ease, opacity 180ms ease;
        }

        .collapsible-card.is-collapsed .card-body {
            max-height: 0;
            opacity: 0;
        }

        .dot {
            display: inline-block;
            width: 10px;
            height: 10px;
            border-radius: 999px;
            margin-right: 8px;
            vertical-align: middle;
        }

        .dot.ok { background: #22c55e; }
        .dot.bad { background: #ef4444; }
        .dot.unknown { background: #9ca3af; }

        #yoloHeaderStatus {
            display: none;
            text-align: right;
            font-size: 12px;
            color: var(--muted);
            line-height: 1.6;
            max-width: 420px;
        }

        #yoloHeaderStatus.is-visible {
            display: block;
        }

        .dataset-clip {
            max-height: 180px;
            overflow: hidden;
            position: relative;
            transition: max-height 260ms ease;
        }

        .dataset-clip::after {
            content: "";
            position: absolute;
            left: 0;
            right: 0;
            bottom: 0;
            height: 46px;
            background: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,1));
            pointer-events: none;
        }

        html[data-theme="dark"] .dataset-clip::after {
            background: linear-gradient(to bottom, rgba(15,23,42,0), rgba(15,23,42,1));
        }

        .dataset-card.is-expanded .dataset-clip {
            overflow: auto;
        }

        .dataset-card.is-expanded .dataset-clip::after {
            display: none;
        }

        .dataset-expand {
            width: 100%;
            border: 1px solid var(--border);
            background: rgba(0,0,0,0.02);
            color: var(--muted);
            border-radius: 10px;
            padding: 8px 10px;
            margin-top: 10px;
            cursor: pointer;
            transition: transform 120ms ease, background 150ms ease;
        }

        html[data-theme="dark"] .dataset-expand {
            background: rgba(255,255,255,0.05);
        }

        .dataset-expand:hover {
            transform: translateY(-1px);
        }

        .mono {
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
        }

        .tabs {
            display: flex;
            gap: 8px;
            align-items: center;
            flex-wrap: wrap;
            margin-top: 10px;
        }

        .tab-btn {
            border: 1px solid rgba(0, 0, 0, 0.10);
            background: rgba(0, 0, 0, 0.03);
            color: inherit;
            padding: 8px 10px;
            border-radius: 10px;
            cursor: pointer;
            transition: transform 120ms ease, background 150ms ease;
        }

        html[data-theme="dark"] .tab-btn {
            border-color: rgba(255, 255, 255, 0.14);
            background: rgba(255, 255, 255, 0.06);
        }

        .tab-btn:hover {
            transform: translateY(-1px);
        }

        .tab-btn.is-active {
            background: rgba(79, 70, 229, 0.14);
            border-color: rgba(79, 70, 229, 0.30);
        }

        .tab-panel { display: none; margin-top: 10px; }
        .tab-panel.is-active { display: block; animation: panelIn 160ms ease both; }

        #datasetReport,
        #datasetYaml,
        #trainCmd,
        #trainStatus,
        #trainLog,
        #terminalLog {
            white-space: pre-wrap;
            word-break: break-word;
            overflow: auto;
            max-height: 320px;
        }
    </style>
</head>
<body>
<div id="pageOverlay" class="page-overlay" aria-hidden="true"><div class="spinner"></div></div>
<div class="app">
    <aside class="sidebar">
        <div class="brand">
            <div class="brand-title">梨园 YOLO 识别演示</div>
            <div class="brand-sub">YOLO + Spring Boot</div>
        </div>
        <nav class="nav">
            <a class="nav-item" data-tab="detect" href="/">目标检测</a>
            <a class="nav-item" data-tab="yolo" href="/yolo">YOLO</a>
            <a class="nav-item" data-tab="terminal" href="/terminal">终端</a>
        </nav>
        <div class="sidebar-bottom">
            <div class="sidebar-actions">
                <button id="settingsToggle" class="icon-btn" type="button" aria-label="settings">
                    <i class="fa-solid fa-gear"></i>
                </button>
                <button id="themeToggle" class="icon-btn" type="button" aria-label="theme">
                    <i class="fa-solid fa-moon"></i>
                </button>
            </div>
        </div>
        <div class="sidebar-footer">脱离 IDE：打包 Jar 直接运行</div>
    </aside>

    <main class="content">
        <header class="header">
            <div>
                <div class="h1">YOLO 目标检测</div>
            </div>
        </header>

        <section id="panelDetect" class="panel">
            <section class="grid">
            <div class="card detect-card">
                <div class="card-title">上传图片</div>
                <div class="card-body">
                    <form class="form" method="post" action="/detect" enctype="multipart/form-data">
                        <div class="note" style="margin: 0 0 6px 0;">模型</div>
                        <select class="file-btn" name="model" style="min-width: 220px;">
                            
                        </select>
                        <input id="modelFileInput" type="file" accept=".pt" style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;" />
                        <label for="modelFileInput" class="icon-btn" aria-label="choose model" title="选择模型文件" style="background:#fff;">
                            <i class="fa-solid fa-folder-open"></i>
                        </label>
                        <button id="applyBestDetect" class="btn-white" type="button">应用 best.pt</button>

                        <div class="note" style="margin: 10px 0 6px 0;">叠加显示/阈值</div>
                        <div class="detect-tune">
                            <div class="tune-item">
                                <div class="tune-label">
                                    <span class="note" style="margin:0;">conf</span>
                                    <span class="help-tip" data-tip="置信度阈值（0-1）。越大越严格：误检更少，但可能漏检。">?</span>
                                </div>
                                <input id="confRange" class="detect-range" type="range" min="0" max="1" step="0.01" value="0.25" />
                                <input id="confInput" class="file-btn detect-number" name="conf" type="number" step="0.01" min="0" max="1" value="0.25" />
                            </div>

                            <div class="tune-item">
                                <div class="tune-label">
                                    <span class="note" style="margin:0;">iou</span>
                                    <span class="help-tip" data-tip="IoU 阈值（0-1），用于 NMS 去重。越小越容易保留更多框；越大越容易合并/抑制重叠框。">?</span>
                                </div>
                                <input id="iouRange" class="detect-range" type="range" min="0" max="1" step="0.01" value="0.7" />
                                <input id="iouInput" class="file-btn detect-number" name="iou" type="number" step="0.01" min="0" max="1" value="0.7" />
                            </div>

                            <div class="tune-item">
                                <div class="tune-label">
                                    <span class="note" style="margin:0;">max_det</span>
                                    <span class="help-tip" data-tip="最多保留的检测框数量。图片目标很多时可以调大，速度可能变慢。">?</span>
                                </div>
                                <input id="maxDetRange" class="detect-range" type="range" min="1" max="5000" step="1" value="300" />
                                <input id="maxDetInput" class="file-btn detect-number" name="maxDet" type="number" step="1" min="1" max="5000" value="300" />
                            </div>

                            <div class="tune-item">
                                <div class="tune-label">
                                    <span class="note" style="margin:0;">line</span>
                                    <span class="help-tip" data-tip="叠加框的线宽（像素）。越大线条越粗。">?</span>
                                </div>
                                <input id="lineWidthRange" class="detect-range" type="range" min="1" max="20" step="1" value="2" />
                                <input id="lineWidthInput" class="file-btn detect-number" name="lineWidth" type="number" step="1" min="1" max="20" value="2" />
                            </div>

                            <div class="tune-item tune-item--checks">
                                <div class="tune-check">
                                    <span class="note" style="margin:0;">labels</span>
                                    <span class="help-tip" data-tip="是否在框旁显示类别标签。">?</span>
                                    <input id="showLabelsInput" name="showLabels" type="checkbox" checked />
                                </div>
                                <div class="tune-check">
                                    <span class="note" style="margin:0;">conf text</span>
                                    <span class="help-tip" data-tip="是否在框旁显示置信度数值。">?</span>
                                    <input id="showConfInput" name="showConf" type="checkbox" checked />
                                </div>
                            </div>
                        </div>
                        <div class="file-wrap">
                            <input id="imageInput" class="file-input" type="file" name="image" accept="image/*" multiple required>
                            <label class="file-btn" for="imageInput">选择图片</label>
                            <span id="fileName" class="file-name">未选择文件</span>
                            <div id="imagePreview" class="thumbs" aria-label="preview"></div>
                        </div>
                        <button class="btn" type="submit">开始识别</button>
                    </form>

                    <div class="error">No static resource sitemap.xml for request &#39;/sitemap.xml&#39;.</div>
                </div>
            </div>

            <div class="card">
                <div class="card-title">叠框结果</div>
                <div class="card-body">
                    
                    <div class="empty">暂无结果，请上传图片后识别</div>
                    
                </div>
            </div>

            <div class="card">
                <div class="card-title">检测列表</div>
                <div class="card-body">
                    
                    <div class="empty">暂无检测结果</div>
                    
                </div>
            </div>

            <div class="card">
                <div class="card-title">接口说明</div>
                <div class="card-body">
                    <div class="note">Web 通过 HTTP 调用 vision 服务：POST /predict（上传字段名为 image）</div>
                </div>
            </div>
            </section>
        </section>

        <section id="panelYolo" class="panel">
            <section class="grid">
                <div class="card collapsible-card span-all dataset-card" data-card-id="dataset" data-default-collapsed="false">
                    <button class="card-header" type="button" aria-expanded="true">
                        <div class="card-title-row">
                            <div class="card-title-text">数据集</div>
                            <div class="dataset-status">
                                <div class="status-item">
                                    <span class="dot  bad"></span>
                                    <span>自动拉起：null</span>
                                </div>
                                <div class="status-item">
                                    <span class="dot  bad"></span>
                                    <span>Python 进程：null</span>
                                </div>
                                
                                <div class="status-item"><span class="dot bad"></span><span>健康检查失败：null</span></div>
                            </div>
                        </div>
                        <div class="card-chevron">▾</div>
                    </button>
                    <div class="card-body">
                        <div class="note">dataset root: -</div>
                        <div class="tabs" id="datasetTabs">
                            <button class="tab-btn is-active" type="button" data-tab="report">REPORT</button>
                            <button class="tab-btn" type="button" data-tab="yaml">data.yaml</button>
                        </div>
                        <div class="dataset-clip" id="datasetClip">
                            <div class="tab-panel is-active" data-tab-panel="report">
                                <pre id="datasetReport" class="note mono"></pre>
                            </div>
                            <div class="tab-panel" data-tab-panel="yaml">
                                <pre id="datasetYaml" class="note mono"></pre>
                            </div>
                        </div>

                        <button id="datasetExpand" class="dataset-expand" type="button">展开查看 ▾</button>
                    </div>
                </div>

                <div class="card collapsible-card" data-card-id="train" data-default-collapsed="false">
                    <button class="card-header" type="button" aria-expanded="true">
                        <div class="card-title">训练</div>
                        <div class="card-chevron">▾</div>
                    </button>
                    <div class="card-body">
                        <div class="note">训练集目录</div>
                        <div class="file-wrap" style="margin-top: 6px; gap: 10px;">
                            <input id="datasetRootInput" class="file-btn" style="flex:1; width:100%; min-width:0;" type="text" value="" placeholder="datasets/pear" />
                            <button id="chooseDatasetRoot" class="icon-btn" type="button" aria-label="choose dataset root" title="选择目录" style="background:#fff;">
                                <i class="fa-solid fa-ellipsis"></i>
                            </button>
                            <button id="resetDatasetRoot" class="icon-btn" type="button" aria-label="reset dataset root" title="恢复默认" style="background:#fff;">
                                <i class="fa-solid fa-rotate-left"></i>
                            </button>
                        </div>

                        <div class="note" style="margin-top: 10px;">训练命令（可复制到终端执行）</div>
                        <pre id="trainCmd" class="note mono"></pre>

                        <div class="file-wrap" style="margin-top: 10px; gap: 10px;">
                            <label class="note" style="margin:0;">epochs</label>
                            <input id="epochs" class="file-btn" style="max-width:120px;" type="number" value="50" min="1" />
                            <label class="note" style="margin:0;">imgsz</label>
                            <input id="imgsz" class="file-btn" style="max-width:120px;" type="number" value="640" min="64" />
                            <button id="startTrain" class="btn" type="button">开始训练</button>
                            <select id="resumeFromSelect" class="file-btn" style="min-width: 220px; max-width: 420px;"></select>
                            <button id="resumeTrain" class="btn" type="button">继续上一次的训练</button>
                        </div>

                        <div id="progressSection">
                            <div class="note" style="margin-top: 10px;">进度</div>
                            <div class="note" style="display:flex; align-items:center; gap:10px;">
                                <div style="flex:1; height:10px; background: rgba(15,23,42,0.08); border-radius: 999px; overflow:hidden;">
                                    <div id="trainProgressBar" style="height:10px; width:0%; background: var(--primary);"></div>
                                </div>
                                <div id="trainProgressText" style="min-width: 120px;">0%</div>
                            </div>
                        </div>

                        <div class="note" style="margin-top: 10px;">训练进度解读</div>
                        <pre id="trainSummary" class="note mono fixed-box" style="margin-top:6px;"> </pre>
                    </div>
                </div>

                <div class="card collapsible-card" id="trainPanel" data-card-id="trainLogs" data-default-collapsed="false">
                    <button class="card-header" type="button" aria-expanded="true">
                        <div class="card-title">训练状态与日志</div>
                        <div class="card-chevron">▾</div>
                    </button>
                    <div class="card-body">
                        <div class="note">状态</div>
                        <pre id="trainStatus" class="note mono fixed-box fade-top"></pre>
                        <div class="note">训练日志（tail）</div>
                        <pre id="trainLog" class="note mono fixed-box fixed-box-lg fade-top"></pre>
                    </div>
                </div>
            </section>
        </section>

        <section id="panelTerminal" class="panel">
            <section class="grid">
                <div class="card collapsible-card span-all" data-card-id="terminal" data-default-collapsed="false">
                    <button class="card-header" type="button" aria-expanded="true">
                        <div class="card-title">终端</div>
                        <div class="card-chevron">▾</div>
                    </button>
                    <div class="card-body">
                        <div class="file-wrap" style="gap:10px; align-items: center;">
                            <button id="terminalRefresh" class="icon-btn" type="button" aria-label="refresh" title="刷新" style="background:#fff;">
                                <i class="fa-solid fa-rotate"></i>
                            </button>
                            <div class="note" style="margin:0;">自动刷新：开启</div>
                        </div>

                        <div class="note" style="margin-top:10px;">输出</div>
                        <pre id="terminalLog" class="note mono fixed-box fixed-box-xl fade-top" style="margin-top:6px;"> </pre>

                        <div class="file-wrap" style="margin-top: 10px; gap: 10px; align-items: center;">
                            <input id="terminalInput" class="file-btn" type="text" placeholder="输入消息并发送" style="flex: 1; width: 100%; min-width: 0;" />
                            <button id="terminalSend" class="btn" type="button">发送</button>
                        </div>
                    </div>
                </div>
            </section>
        </section>

        <section id="panelSettings" class="panel">
            <section class="grid">
                <div class="card span-all">
                    <div class="card-title">设置</div>
                    <div class="card-body">
                        <div class="settings-section">
                            <div class="note">语言</div>
                            <div class="file-wrap" style="gap:10px;">
                                <a class="file-btn" href="/?lang=zh_CN">中文</a>
                                <a class="file-btn" href="/?lang=en">English</a>
                            </div>
                        </div>

                        <div class="settings-section">
                            <div class="note">主题色</div>
                            <div class="file-wrap" style="gap:10px;">
                                <input id="primaryColor" class="file-btn" type="color" value="#00a85a" style="width: 54px; padding: 6px;" />
                                <button id="resetPrimary" class="btn" type="button">恢复默认</button>
                            </div>
                        </div>

                        <div class="settings-section">
                            <div class="note">项目简介</div>
                            <div class="note">Pear Orchard Intelligent Robot：Spring Boot + Thymeleaf 前端，FastAPI + YOLOv8 视觉服务，支持目标检测、训练与模型管理。</div>
                            <div class="note">vision 服务地址：null</div>
                        </div>
                    </div>
                </div>
            </section>
        </section>
    </main>
</div>

<button id="backToTop" type="button" aria-label="back to top">
    <i class="fa-solid fa-arrow-up"></i>
</button>

<script>
    (function () {
        var input = document.getElementById('imageInput');
        var name = document.getElementById('fileName');
        var preview = document.getElementById('imagePreview');
        if (!input || !name) return;
        input.addEventListener('change', function () {
            if (!input.files || input.files.length <= 0) return;
            if (input.files.length === 1) {
                name.textContent = input.files[0].name;
            } else {
                name.textContent = '已选择 ' + input.files.length + ' 张';
            }

            if (!preview) return;
            preview.innerHTML = '';
            Array.from(input.files).slice(0, 8).forEach(function (f) {
                try {
                    var url = URL.createObjectURL(f);
                    var img = document.createElement('img');
                    img.className = 'thumb';
                    img.alt = f.name;
                    img.src = url;
                    preview.appendChild(img);
                } catch (e) {
                }
            });
        });
    })();

    (function () {
        var fileInput = document.getElementById('modelFileInput');
        if (!fileInput) return;

        async function uploadFile(f) {
            if (!f) return;
            var fd = new FormData();
            fd.append('file', f);
            var r = await fetch('/yolo/model/upload', { method: 'POST', body: fd, headers: { 'Accept': 'application/json' } });
            var ct = (r.headers && r.headers.get) ? (r.headers.get('content-type') || '') : '';
            if (ct.indexOf('application/json') < 0) {
                var t = await r.text();
                throw new Error('Unexpected response (' + r.status + '): ' + t);
            }
            var j = await r.json();
            if (j && j.ok && j.model) {
                try {
                    var url = new URL(window.location.href);
                    url.searchParams.set('model', String(j.model));
                    window.location.href = url.toString();
                } catch (e) {
                    window.location.reload();
                }
                return;
            }
            if (j && j.error) throw new Error(String(j.error));
        }

        if (fileInput) {
            fileInput.addEventListener('change', async function () {
                try {
                    if (fileInput.files && fileInput.files[0]) {
                        await uploadFile(fileInput.files[0]);
                    }
                } catch (e) {
                    alert(String(e));
                } finally {
                    try { fileInput.value = ''; } catch (e) {}
                }
            });
        }

    })();

    (function () {
        var KEY = 'detectParams:v1';

        function num(n, min, max, fallback) {
            var v = Number(n);
            if (!isFinite(v)) return fallback;
            if (min != null) v = Math.max(min, v);
            if (max != null) v = Math.min(max, v);
            return v;
        }

        function readSaved() {
            try {
                var s = localStorage.getItem(KEY);
                if (!s) return null;
                return JSON.parse(s);
            } catch (e) {
                return null;
            }
        }

        function save(p) {
            try { localStorage.setItem(KEY, JSON.stringify(p)); } catch (e) {}
        }

        var confRange = document.getElementById('confRange');
        var confInput = document.getElementById('confInput');
        var iouRange = document.getElementById('iouRange');
        var iouInput = document.getElementById('iouInput');
        var maxDetRange = document.getElementById('maxDetRange');
        var maxDetInput = document.getElementById('maxDetInput');
        var lineWidthRange = document.getElementById('lineWidthRange');
        var lineWidthInput = document.getElementById('lineWidthInput');
        var showLabelsInput = document.getElementById('showLabelsInput');
        var showConfInput = document.getElementById('showConfInput');

        if (!confInput || !confRange) return;

        var defaults = {
            conf: num(confInput.value, 0, 1, 0.25),
            iou: num(iouInput ? iouInput.value : 0.7, 0, 1, 0.7),
            maxDet: num(maxDetInput ? maxDetInput.value : 300, 1, 5000, 300),
            lineWidth: num(lineWidthInput ? lineWidthInput.value : 2, 1, 20, 2),
            showLabels: showLabelsInput ? !!showLabelsInput.checked : true,
            showConf: showConfInput ? !!showConfInput.checked : true
        };

        var saved = readSaved();
        var state = Object.assign({}, defaults, saved || {});

        function applyState(s) {
            state = Object.assign({}, state, s || {});

            var confV = num(state.conf, 0, 1, defaults.conf);
            if (confInput) confInput.value = String(confV);
            if (confRange) confRange.value = String(confV);

            var iouV = num(state.iou, 0, 1, defaults.iou);
            if (iouInput) iouInput.value = String(iouV);
            if (iouRange) iouRange.value = String(iouV);

            var maxDetV = num(state.maxDet, 1, 5000, defaults.maxDet);
            if (maxDetInput) maxDetInput.value = String(maxDetV);
            if (maxDetRange) maxDetRange.value = String(maxDetV);

            var lineWidthV = num(state.lineWidth, 1, 20, defaults.lineWidth);
            if (lineWidthInput) lineWidthInput.value = String(lineWidthV);
            if (lineWidthRange) lineWidthRange.value = String(lineWidthV);

            if (showLabelsInput) showLabelsInput.checked = !!state.showLabels;
            if (showConfInput) showConfInput.checked = !!state.showConf;

            save(state);
        }

        function bindPair(rangeEl, inputEl, key, min, max, fallback) {
            if (rangeEl) {
                rangeEl.addEventListener('input', function () {
                    var v = num(rangeEl.value, min, max, fallback);
                    var patch = {}; patch[key] = v;
                    applyState(patch);
                });
            }
            if (inputEl) {
                inputEl.addEventListener('input', function () {
                    var v = num(inputEl.value, min, max, fallback);
                    var patch = {}; patch[key] = v;
                    applyState(patch);
                });
            }
        }

        bindPair(confRange, confInput, 'conf', 0, 1, defaults.conf);
        bindPair(iouRange, iouInput, 'iou', 0, 1, defaults.iou);
        bindPair(maxDetRange, maxDetInput, 'maxDet', 1, 5000, defaults.maxDet);
        bindPair(lineWidthRange, lineWidthInput, 'lineWidth', 1, 20, defaults.lineWidth);

        if (showLabelsInput) {
            showLabelsInput.addEventListener('change', function () {
                applyState({ showLabels: !!showLabelsInput.checked });
            });
        }
        if (showConfInput) {
            showConfInput.addEventListener('change', function () {
                applyState({ showConf: !!showConfInput.checked });
            });
        }

        applyState(state);
    })();

    (function () {
        function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
        function hexToRgb(hex) {
            var h = (hex || '').replace('#', '').trim();
            if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
            if (h.length !== 6) return null;
            var r = parseInt(h.slice(0,2), 16);
            var g = parseInt(h.slice(2,4), 16);
            var b = parseInt(h.slice(4,6), 16);
            if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
            return { r:r, g:g, b:b };
        }
        function rgbToHex(r,g,b) {
            function h(n){ var s=n.toString(16); return s.length===1?'0'+s:s; }
            return '#' + h(r) + h(g) + h(b);
        }
        function darken(hex, amount) {
            var rgb = hexToRgb(hex);
            if (!rgb) return '#008b45';
            var r = clamp(Math.round(rgb.r * (1 - amount)), 0, 255);
            var g = clamp(Math.round(rgb.g * (1 - amount)), 0, 255);
            var b = clamp(Math.round(rgb.b * (1 - amount)), 0, 255);
            return rgbToHex(r,g,b);
        }

        var input = document.getElementById('primaryColor');
        var reset = document.getElementById('resetPrimary');
        if (!input) return;
        var DEFAULT = '#00a85a';

        function apply(color) {
            document.documentElement.style.setProperty('--primary', color);
            document.documentElement.style.setProperty('--primary-2', color);
            document.documentElement.style.setProperty('--primary-hover', darken(color, 0.15));
        }

        var saved = null;
        try { saved = localStorage.getItem('primaryColor'); } catch (e) {}
        var initial = saved && saved.trim() ? saved : DEFAULT;
        input.value = initial;
        apply(initial);

        input.addEventListener('input', function () {
            var c = input.value;
            apply(c);
            try { localStorage.setItem('primaryColor', c); } catch (e) {}
        });
        if (reset) {
            reset.addEventListener('click', function () {
                input.value = DEFAULT;
                apply(DEFAULT);
                try { localStorage.removeItem('primaryColor'); } catch (e) {}
            });
        }
    })();

    (function () {
        var datasetCard = document.querySelector('.dataset-card');
        var expandBtn = document.getElementById('datasetExpand');
        var clip = document.getElementById('datasetClip');
        if (!datasetCard || !expandBtn) return;

        var expanded = false;
        function render() {
            datasetCard.classList.toggle('is-expanded', expanded);
            expandBtn.textContent = expanded ? '收起 ▴' : '展开查看 ▾';

            if (clip) {
                if (expanded) {
                    var maxPx = Math.max(220, Math.round(window.innerHeight * 0.6));
                    var target = Math.min(clip.scrollHeight, maxPx);
                    clip.style.maxHeight = target + 'px';
                } else {
                    var h = clip.scrollHeight;
                    clip.style.maxHeight = h + 'px';
                    clip.getBoundingClientRect();
                    clip.style.maxHeight = '180px';
                }
            }
        }

        expandBtn.addEventListener('click', function () {
            expanded = !expanded;
            render();
        });
        render();
    })();

    (function () {
        var btn = document.getElementById('backToTop');
        var content = document.querySelector('main.content');
        if (!btn || !content) return;

        function update() {
            btn.style.display = content.scrollTop > 220 ? 'inline-flex' : 'none';
        }

        content.addEventListener('scroll', update, { passive: true });
        window.addEventListener('load', update);

        btn.addEventListener('click', function () {
            try {
                content.scrollTo({ top: 0, behavior: 'smooth' });
            } catch (e) {
                content.scrollTop = 0;
            }
        });
    })();

    (function () {
        var btn = document.getElementById('themeToggle');
        if (!btn) return;

        function applyTheme(theme) {
            document.documentElement.setAttribute('data-theme', theme);
            var icon = btn.querySelector('i');
            if (icon) {
                icon.className = theme === 'dark' ? 'fa-solid fa-sun' : 'fa-solid fa-moon';
            }
        }

        var saved = localStorage.getItem('theme');
        var theme = saved === 'dark' ? 'dark' : 'light';
        applyTheme(theme);

        btn.addEventListener('click', function () {
            var current = document.documentElement.getAttribute('data-theme');
            var next = current === 'dark' ? 'light' : 'dark';
            localStorage.setItem('theme', next);
            applyTheme(next);
        });
    })();

    (function () {
        var overlay = document.getElementById('pageOverlay');
        var content = document.querySelector('main.content');
        var active = (content && content.getAttribute('data-active-tab')) || 'detect';
        var settingsToggle = document.getElementById('settingsToggle');

        function hideOverlay() {
            if (overlay) {
                overlay.classList.add('is-hidden');
                setTimeout(function () {
                    try { overlay.remove(); } catch (e) {}
                }, 280);
            }
        }

        function setActiveTab(tab, push, href) {
            active = tab;
            var panels = {
                detect: document.getElementById('panelDetect'),
                yolo: document.getElementById('panelYolo'),
                terminal: document.getElementById('panelTerminal'),
                settings: document.getElementById('panelSettings')
            };
            Object.keys(panels).forEach(function (k) {
                if (panels[k]) panels[k].classList.toggle('is-active', k === tab);
            });

            var navLinks = document.querySelectorAll('.nav a.nav-item[data-tab]');
            navLinks.forEach(function (a) {
                a.classList.toggle('active', a.getAttribute('data-tab') === tab);
            });

            if (push && href) {
                try {
                    history.pushState({ tab: tab }, '', href);
                } catch (e) {
                }
            }
        }

        window.addEventListener('load', function () {
            if (content) content.classList.add('is-ready');
            hideOverlay();
            setActiveTab(active, false, null);
        });

        window.addEventListener('popstate', function () {
            var p = location.pathname || '/';
            if (p === '/yolo') return setActiveTab('yolo', false, null);
            if (p === '/terminal') return setActiveTab('terminal', false, null);
            if (p === '/settings') return setActiveTab('settings', false, null);
            return setActiveTab('detect', false, null);
        });

        var navLinks = document.querySelectorAll('.nav a.nav-item[data-tab]');
        navLinks.forEach(function (a) {
            a.addEventListener('click', function (e) {
                var href = a.getAttribute('href');
                var tab = a.getAttribute('data-tab');
                if (!tab) return;
                if (tab === active) return;
                e.preventDefault();
                setActiveTab(tab, true, href);
            });
        });

        if (settingsToggle) {
            settingsToggle.addEventListener('click', function () {
                if (active === 'settings') return;
                setActiveTab('settings', true, '/settings');
            });
        }

        var sidebar = document.querySelector('aside.sidebar');
        if (sidebar && content) {
            sidebar.addEventListener('wheel', function (e) {
                try {
                    content.scrollTop += e.deltaY;
                    e.preventDefault();
                } catch (err) {
                }
            }, { passive: false });
        }
    })();

    (function () {
        var datasetTabs = document.getElementById('datasetTabs');
        if (!datasetTabs) return;
        function setTab(tab) {
            var btns = datasetTabs.querySelectorAll('.tab-btn');
            btns.forEach(function (b) {
                b.classList.toggle('is-active', b.getAttribute('data-tab') === tab);
            });
            var panels = document.querySelectorAll('[data-tab-panel]');
            panels.forEach(function (p) {
                p.classList.toggle('is-active', p.getAttribute('data-tab-panel') === tab);
            });
        }
        datasetTabs.addEventListener('click', function (e) {
            var t = e.target;
            if (!t || !t.classList || !t.classList.contains('tab-btn')) return;
            var tab = t.getAttribute('data-tab');
            if (!tab) return;
            setTab(tab);
        });
        setTab('report');
    })();

    (function () {
        var cards = document.querySelectorAll('.collapsible-card[data-card-id]');
        function storageKey(id) { return 'card:' + id + ':collapsed'; }
        function setCollapsed(card, collapsed) {
            if (!card) return;
            card.classList.toggle('is-collapsed', !!collapsed);
            var header = card.querySelector('.card-header');
            if (header) header.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
        }
        cards.forEach(function (card) {
            var id = card.getAttribute('data-card-id');
            if (!id) return;
            var collapsed = null;
            try {
                var saved = localStorage.getItem(storageKey(id));
                if (saved === '1') collapsed = true;
                if (saved === '0') collapsed = false;
            } catch (e) {
            }
            if (collapsed == null) {
                collapsed = card.getAttribute('data-default-collapsed') === 'true';
            }
            setCollapsed(card, collapsed);
            var header = card.querySelector('.card-header');
            if (header) {
                header.addEventListener('click', function () {
                    var nowCollapsed = !card.classList.contains('is-collapsed');
                    setCollapsed(card, nowCollapsed);
                    try { localStorage.setItem(storageKey(id), nowCollapsed ? '1' : '0'); } catch (e) {}
                });
            }
        });
    })();

    (function () {
        var defaultRoot = document.getElementById('datasetRootInput') ? document.getElementById('datasetRootInput').value : '';
        var useDefault = document.getElementById('resetDatasetRoot');
        var chooseBtn = document.getElementById('chooseDatasetRoot');
        var datasetRootInput = document.getElementById('datasetRootInput');
        if (useDefault && datasetRootInput) {
            useDefault.addEventListener('click', function () {
                datasetRootInput.value = defaultRoot;
            });
        }

        if (chooseBtn && datasetRootInput) {
            chooseBtn.addEventListener('click', async function () {
                try {
                    var body = new URLSearchParams();
                    if (datasetRootInput.value) body.set('current', datasetRootInput.value);
                    var r = await fetch('/yolo/dataset/choose', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                        body: body.toString()
                    });
                    var j = await r.json();
                    if (j && j.ok && j.path) {
                        datasetRootInput.value = j.path;
                        return;
                    }
                    if (j && j.error && j.error !== 'canceled') {
                        alert(String(j.error));
                    }
                } catch (e) {
                    alert(String(e));
                }
            });
        }
    })();

    (function () {
        var startBtn = document.getElementById('startTrain');
        var resumeBtn = document.getElementById('resumeTrain');
        var resumeFromEl = document.getElementById('resumeFromSelect');
        var applyBestDetectBtn = document.getElementById('applyBestDetect');
        var statusEl = document.getElementById('trainStatus');
        var logEl = document.getElementById('trainLog');
        var summaryEl = document.getElementById('trainSummary');
        var epochsEl = document.getElementById('epochs');
        var imgszEl = document.getElementById('imgsz');
        var datasetRootInput = document.getElementById('datasetRootInput');
        var barEl = document.getElementById('trainProgressBar');
        var barTextEl = document.getElementById('trainProgressText');
        var progressSection = document.getElementById('progressSection');

        function setText(el, text) {
            if (!el) return;
            el.textContent = text == null ? '' : String(text);
        }

        function parseTrainSummary(logText) {
            var text = String(logText || '');
            var lines = text.split(/\r?\n/);
            var line = '';
            for (var i = lines.length - 1; i >= 0; i--) {
                var s = (lines[i] || '').trim();
                if (!s) continue;
                if (/(\b\d+\s*\/\s*\d+\b)/.test(s) || /epoch\s*\d+\s*\/\s*\d+/i.test(s) || /it\//i.test(s)) {
                    line = s;
                    break;
                }
            }

            var epochCur = null, epochTot = null;
            var m = line.match(/\b(\d+)\s*\/\s*(\d+)\b/);
            if (m) {
                epochCur = parseInt(m[1], 10);
                epochTot = parseInt(m[2], 10);
            }
            var m2 = line.match(/epoch\s*(\d+)\s*\/\s*(\d+)/i);
            if (m2) {
                epochCur = parseInt(m2[1], 10);
                epochTot = parseInt(m2[2], 10);
            }

            var eta = null;
            var mEta = line.match(/\b(\d{1,2}:\d{2}:\d{2})\b/);
            if (mEta) eta = mEta[1];
            var mEta2 = line.match(/\bETA\s*([0-9:]+)\b/i);
            if (mEta2) eta = mEta2[1];

            var speed = null;
            var mIt = line.match(/\b(\d+(?:\.\d+)?)it\/s\b/i);
            if (mIt) speed = mIt[1] + ' it/s';
            var mS = line.match(/\b(\d+(?:\.\d+)?)s\/it\b/i);
            if (!speed && mS) speed = mS[1] + ' s/it';

            var parts = [];
            if (epochCur != null && epochTot != null && epochTot > 0) {
                parts.push('训练轮次：' + epochCur + '/' + epochTot);
                var pct = Math.max(0, Math.min(100, Math.round((epochCur / epochTot) * 100)));
                parts.push('完成：' + pct + '%');
            }
            if (eta) parts.push('预计剩余：' + eta);
            if (speed) parts.push('速度：' + speed);

            if (parts.length === 0) {
                return '等待训练输出…';
            }
            return parts.join('\n');
        }

        function setProgress(percent, cur, total) {
            if (percent == null || isNaN(percent)) percent = 0;
            percent = Math.max(0, Math.min(100, Number(percent)));
            if (barEl) barEl.style.width = percent + '%';
            var extra = '';
            if (cur != null && total != null) {
                extra = ' (' + cur + '/' + total + ')';
            }
            setText(barTextEl, percent + '%' + extra);
        }

        function setVisible(el, visible) {
            if (!el) return;
            el.style.display = visible ? '' : 'none';
        }

        function updateUiByStatus(st) {
            var started = !!(st && st.startedAt);
            var running = !!(st && st.running);
            setVisible(progressSection, started);
            setVisible(resumeBtn, true);

            if (resumeBtn) resumeBtn.disabled = running;
            if (resumeFromEl) resumeFromEl.disabled = running;

            if (startBtn) {
                startBtn.textContent = running ? '终止训练' : '开始训练';
                startBtn.classList.toggle('btn-danger', running);
            }
        }

        function option(el, value, text, selected) {
            if (!el) return;
            var o = document.createElement('option');
            o.value = value;
            o.textContent = text;
            if (selected) o.selected = true;
            el.appendChild(o);
        }

        async function loadResumeRuns() {
            if (!resumeFromEl) return;
            resumeFromEl.innerHTML = '';
            option(resumeFromEl, '', '自动选择最近一次', true);
            try {
                var r = await fetch('/yolo/train/runs', { method: 'GET', headers: { 'Accept': 'application/json' } });
                var ct = (r.headers && r.headers.get) ? (r.headers.get('content-type') || '') : '';
                if (ct.indexOf('application/json') < 0) {
                    return;
                }
                var j = await r.json();
                if (!j || !j.ok || !j.runs || !Array.isArray(j.runs)) return;
                j.runs.forEach(function (it, idx) {
                    if (!it) return;
                    option(resumeFromEl, String(it.lastPt || ''), String(it.name || it.lastPt || ('run ' + idx)), false);
                });
            } catch (e) {
            }
        }

        async function fetchStatus() {
            try {
                var r = await fetch('/yolo/train/status', { method: 'GET' });
                var j = await r.json();
                if (j && j.status) {
                    setText(statusEl, JSON.stringify(j.status, null, 2));
                    setProgress(j.status.progressPercent, j.status.epochCurrent, j.status.epochTotal);
                    updateUiByStatus(j.status);
                }
            } catch (e) {
                setText(statusEl, String(e));
            }
        }

        async function fetchLog() {
            try {
                var r = await fetch('/yolo/train/log?maxChars=12000', { method: 'GET' });
                var j = await r.json();
                if (j && j.log != null) {
                    var prev = logEl ? logEl.textContent : '';
                    setText(logEl, j.log);
                    setText(summaryEl, parseTrainSummary(j.log));
                    if (logEl && j.log !== prev) {
                        try { logEl.scrollTop = logEl.scrollHeight; } catch (e) {}
                    }
                }
            } catch (e) {
                setText(logEl, String(e));
            }
        }

        async function startTrain(resume) {
            var epochs = epochsEl ? epochsEl.value : '50';
            var imgsz = imgszEl ? imgszEl.value : '640';
            var ds = datasetRootInput ? datasetRootInput.value : '';
            var body = new URLSearchParams();
            body.set('epochs', epochs);
            body.set('imgsz', imgsz);
            body.set('resume', resume ? 'true' : 'false');
            if (resume && resumeFromEl && resumeFromEl.value) {
                body.set('resumeFrom', resumeFromEl.value);
            }
            if (ds) body.set('datasetRoot', ds);
            var r = await fetch('/yolo/train/start', {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: body.toString()
            });
            var j = await r.json();
            if (j && j.status) {
                setText(statusEl, JSON.stringify(j.status, null, 2));
                setProgress(j.status.progressPercent, j.status.epochCurrent, j.status.epochTotal);
                updateUiByStatus(j.status);
            }
        }

        async function stopTrain() {
            var r = await fetch('/yolo/train/stop', { method: 'POST' });
            var j = await r.json();
            if (j && j.status) {
                setText(statusEl, JSON.stringify(j.status, null, 2));
                setProgress(j.status.progressPercent, j.status.epochCurrent, j.status.epochTotal);
                updateUiByStatus(j.status);
            }
        }

        async function applyBest() {
            var r = await fetch('/yolo/model/use-server-best', { method: 'POST', headers: { 'Accept': 'application/json' } });
            var j = await r.json();
            if (j && j.ok && j.model) {
                try {
                    var url = new URL(window.location.href);
                    url.searchParams.set('model', String(j.model));
                    window.location.href = url.toString();
                } catch (e) {
                    window.location.reload();
                }
                return;
            }
            if (j && j.error) {
                alert(String(j.error));
            }
        }

        if (startBtn) startBtn.addEventListener('click', async function () {
            try {
                var r = await fetch('/yolo/train/status', { method: 'GET' });
                var j = await r.json();
                var running = !!(j && j.status && j.status.running);
                if (running) {
                    await stopTrain();
                } else {
                    await startTrain(false);
                }
            } catch (e) {
                await startTrain(false);
            }
        });
        if (resumeBtn) resumeBtn.addEventListener('click', function () { startTrain(true); });
        if (applyBestDetectBtn) applyBestDetectBtn.addEventListener('click', function () { applyBest(); });

        loadResumeRuns();
        fetchStatus();
        fetchLog();
        setInterval(function () {
            fetchStatus();
            fetchLog();
        }, 2000);
    })();

    (function () {
        var terminalEl = document.getElementById('terminalLog');
        var refreshBtn = document.getElementById('terminalRefresh');
        var inputEl = document.getElementById('terminalInput');
        var sendBtn = document.getElementById('terminalSend');
        if (!terminalEl) return;
        async function fetchTerminal() {
            try {
                var r = await fetch('/terminal/log?maxChars=16000', { method: 'GET' });
                var j = await r.json();
                if (j && j.log != null) terminalEl.textContent = j.log;
            } catch (e) {
                terminalEl.textContent = String(e);
            }
        }
        async function sendMessage() {
            if (!inputEl) return;
            var msg = (inputEl.value || '').trim();
            if (!msg) return;
            try {
                var body = new URLSearchParams();
                body.set('message', msg);
                var r = await fetch('/terminal/send', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: body.toString()
                });
                var j = await r.json();
                if (j && !j.ok && j.error) {
                    throw new Error(String(j.error));
                }
                inputEl.value = '';
                fetchTerminal();
            } catch (e) {
                alert(String(e));
            }
        }

        if (refreshBtn) refreshBtn.addEventListener('click', function () { fetchTerminal(); });
        if (sendBtn) sendBtn.addEventListener('click', function () { sendMessage(); });
        if (inputEl) {
            inputEl.addEventListener('keydown', function (e) {
                if (e.key === 'Enter') {
                    e.preventDefault();
                    sendMessage();
                }
            });
        }
        fetchTerminal();
        setInterval(function () {
            fetchTerminal();
        }, 1500);
    })();
</script>
</body>
</html>
