Spaces:
Running
Running
stouchinglhh
Claude
sync processor from GitLab c697b6e: 版本 1.19.4 / 前端 1.12.4 —— 服务端 i18n(首屏不再闪英文)、推理模块并入模型页、前端抽出 shared/、缓存键改用前端号
bb59211 Download processor/web/templates/base.html from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 20.9 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/templates/base.html
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/web/templates/base.html
-
curl -L -o base.html https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/web/templates/base.html
20.9 kB
| <html lang="{{ current_lang() }}"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| {% set boot_user = request.state.auth_user|default({}) %} | |
| <title>{% block title %}processor{% endblock %}</title> | |
| <link rel="stylesheet" href="/static/plyr.css" /> | |
| <link rel="stylesheet" href="/static/tailwind-generated.css?v={{ asset_version }}"> | |
| <script src="/static/iconify-preload.js"></script> | |
| <script src="/static/iconify-icon.min.js"></script> | |
| <script src="/static/plyr.js"></script> | |
| <!-- 词典要**先于** i18n.js 加载:后者只带逻辑,语言表从这个文件取。 | |
| 它同时被服务端读(app/i18n.py),两边读同一份才不会分叉。 --> | |
| <script src="/static/js/i18n-data.js?v={{ asset_version }}"></script> | |
| <script src="/static/js/i18n.js?v={{ asset_version }}"></script> | |
| <style> | |
| :root { | |
| --plyr-color-main: #3b82f6; | |
| } | |
| .episode-card { cursor: pointer; } | |
| .episode-card.active { background-color: #1e3a5f; border-left: 3px solid #3b82f6; } | |
| /* Plyr — fill aspect-video container */ | |
| #video-grid { min-width: 0; min-height: 0; overflow: hidden; } | |
| #video-grid .plyr, | |
| #video-grid .plyr__video-wrapper, | |
| #video-grid .plyr video { | |
| width: 100%; height: 100%; min-width: 0; min-height: 0; | |
| } | |
| #video-grid .plyr video { object-fit: contain; } | |
| .hide-scrollbar { | |
| scrollbar-width: none; | |
| -ms-overflow-style: none; | |
| } | |
| .hide-scrollbar::-webkit-scrollbar { | |
| display: none; | |
| } | |
| /* Iconify icons */ | |
| iconify-icon { display: inline-block; vertical-align: -0.125em; } | |
| .icon-sm { font-size: 14px; } | |
| .icon-md { font-size: 18px; } | |
| /* Frame-aligned mode: Plyr falls back to its default control bar when | |
| `controls: []` — hide it entirely. The central play button stays so | |
| clicking any view plays the whole frame-aligned set. */ | |
| .plyr__controls { display: none ; } | |
| .plyr__control--overlaid { display: inline-flex ; } | |
| .plyr--video .plyr__control.plyr__tab-focus { box-shadow: none; } | |
| /* Sidebar */ | |
| .sidebar-link { | |
| display: flex; align-items: center; gap: 10px; | |
| padding: 10px 16px; border-radius: 6px; margin: 2px 8px; | |
| color: #9ca3af; font-size: 14px; transition: all 0.15s; | |
| text-decoration: none; white-space: nowrap; | |
| } | |
| .sidebar-link:hover { background: #1f2937; color: #e5e7eb; } | |
| .sidebar-link.active { | |
| background: #1e3a5f; color: #93c5fd; | |
| border-left: 3px solid #3b82f6; margin-left: 5px; | |
| padding-left: 13px; border-radius: 0 6px 6px 0; | |
| } | |
| .sidebar-sub { | |
| display: flex; align-items: center; gap: 8px; | |
| padding: 7px 16px 7px 44px; margin: 1px 8px; border-radius: 6px; | |
| color: #6b7280; font-size: 13px; transition: all 0.15s; | |
| text-decoration: none; white-space: nowrap; | |
| } | |
| .sidebar-sub:hover { background: #1f2937; color: #d1d5db; } | |
| .sidebar-sub.active { | |
| color: #93c5fd; background: #111827; | |
| } | |
| .sidebar-group { overflow: hidden; transition: max-height 0.25s ease; } | |
| .sidebar-group.collapsed { max-height: 0 ; } | |
| /* Toggle button — rounded square with triangle icon */ | |
| .sidebar-toggle { | |
| display: inline-flex; align-items: center; justify-content: center; | |
| width: 20px; height: 20px; border-radius: 6px; margin-left: auto; flex-shrink: 0; | |
| background: #1f2937; color: #6b7280; font-size: 12px; | |
| transition: background 0.15s, color 0.15s; | |
| } | |
| .sidebar-link:hover .sidebar-toggle { background: #374151; color: #9ca3af; } | |
| .sidebar-link.active .sidebar-toggle { background: #1e3a5f; color: #93c5fd; } | |
| .sidebar-toggle-icon { transition: transform 0.25s ease; display: inline-flex; } | |
| .collapsed-toggle .sidebar-toggle-icon { transform: rotate(-90deg); } | |
| /* L2 toggle — smaller */ | |
| .sidebar-toggle-sm { | |
| display: inline-flex; align-items: center; justify-content: center; | |
| width: 16px; height: 16px; border-radius: 4px; margin-left: auto; flex-shrink: 0; | |
| background: #1f2937; color: #6b7280; font-size: 10px; | |
| transition: background 0.15s, color 0.15s; | |
| } | |
| .sidebar-sub:hover .sidebar-toggle-sm { background: #374151; color: #9ca3af; } | |
| .sidebar-sub.active .sidebar-toggle-sm { background: #1e3a5f; color: #93c5fd; } | |
| .sidebar-toggle-icon-sm { transition: transform 0.25s ease; display: inline-flex; } | |
| .collapsed-toggle .sidebar-toggle-icon-sm { transform: rotate(-90deg); } | |
| .sidebar-leaf { | |
| display: flex; align-items: center; gap: 6px; | |
| padding: 3px 16px 3px 56px; margin: 1px 8px; border-radius: 4px; | |
| color: #6b7280; font-size: 12px; transition: all 0.15s; | |
| text-decoration: none; white-space: nowrap; | |
| } | |
| .sidebar-leaf:hover { background: #1f2937; color: #d1d5db; } | |
| .sidebar-leaf.active { color: #93c5fd; background: #111827; } | |
| .sidebar-sub-group { overflow: hidden; transition: max-height 0.25s ease; } | |
| .sidebar-sub-group.collapsed { max-height: 0 ; } | |
| .sidebar-sub-chevron { transition: transform 0.2s; font-size: 10px; margin-left: auto; } | |
| /* Skeleton pulse */ | |
| @keyframes skeleton-pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.4; } | |
| } | |
| .skeleton { animation: skeleton-pulse 1.5s ease-in-out infinite; } | |
| </style> | |
| </head> | |
| <body class="bg-gray-950 text-gray-100 h-screen flex overflow-hidden"> | |
| <!-- Left Sidebar --> | |
| <aside class="flex flex-col bg-gray-900 border-r border-gray-800 flex-shrink-0" style="width:220px"> | |
| <!-- Brand --> | |
| <div class="px-4 py-3 border-b border-gray-800"> | |
| <div class="flex items-center gap-2"> | |
| <img src="/static/logo.png" alt="EgoData" class="w-8 h-auto flex-shrink-0"> | |
| <span class="text-sm font-semibold text-gray-200">EgoData</span> | |
| </div> | |
| </div> | |
| <!-- Nav --> | |
| <nav class="flex-1 py-3 flex flex-col gap-0.5"> | |
| <a href="/" class="sidebar-link" data-page="home"> | |
| <iconify-icon icon="ant-design:home-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="nav_home">{{ t('nav_home') }}</span> | |
| </a> | |
| <a href="/tasks" class="sidebar-link" data-page="tasks"> | |
| <iconify-icon icon="ant-design:project-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="nav_tasks">{{ t('nav_tasks') }}</span> | |
| </a> | |
| <a href="/workflow-studio" class="sidebar-link" data-page="workflow-studio"> | |
| <iconify-icon icon="ant-design:apartment-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="nav_workflow">{{ t('nav_workflow') }}</span> | |
| </a> | |
| <a href="/users" id="users-nav" class="sidebar-link{% if boot_user.role|default('') != 'admin' %} hidden{% endif %}" data-page="users"> | |
| {# 图标必须取自 iconify-preload.js 白名单 —— 此处在每个页面的侧边栏, | |
| 用白名单外的名字会让每次翻页都去 api.iconify.design 拉一次。 | |
| team-outlined 不在表内,改用同义的 user-outlined。 #} | |
| <iconify-icon icon="ant-design:user-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="nav_users">{{ t('nav_users') }}</span> | |
| </a> | |
| <a href="/annotation-studio" class="sidebar-link" data-page="annotation-studio"> | |
| <iconify-icon icon="ant-design:edit-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="nav_annotation">{{ t('nav_annotation') }}</span> | |
| </a> | |
| <!-- Video Review — status shortcuts; the project→task→video | |
| hierarchy list renders on the right side of the page --> | |
| <a href="/review" class="sidebar-link" data-page="review"> | |
| <iconify-icon icon="ant-design:video-camera-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="nav_review">{{ t('nav_review') }}</span> | |
| </a> | |
| <a href="/review?status=completed" class="sidebar-sub" data-review-status="reviewing"> | |
| <iconify-icon icon="ant-design:eye-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="stat_reviewing">{{ t('stat_reviewing') }}</span> | |
| </a> | |
| <a href="/review?status=reviewed" class="sidebar-sub" data-review-status="approved"> | |
| <iconify-icon icon="ant-design:check-circle-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="stat_approved">{{ t('stat_approved') }}</span> | |
| </a> | |
| <a href="/review?status=failed" class="sidebar-sub" data-review-status="failed"> | |
| <iconify-icon icon="ant-design:warning-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="stat_failed">{{ t('stat_failed') }}</span> | |
| </a> | |
| <!-- 模型 — 一级项,与 Video Review **同级同款**(图标 + 可点击 + | |
| 选中蓝底高亮),下面挂训练/部署两个二级短名项。 | |
| 一级项暂时指向 /train:它目前是唯一的二级页。将来有了 /deploy, | |
| 这里应该改成一个模型总览页(Video Review 指向 /review 的写法同理)。 --> | |
| <!-- 「模型」是**一级项,点进去直接就是那一页** —— 页内三个页签 | |
| (模型训练 / 新建训练 / 模型推理)自己分工。 | |
| 早先这里是两个二级项(训练 / 推理),但它们指向两个页面,而两者 | |
| 本来是同一批产物的上下游,分两个导航项反而要来回跳。 --> | |
| <a href="/train" class="sidebar-link" data-page="model"> | |
| <iconify-icon icon="ant-design:robot-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="nav_model">{{ t('nav_model') }}</span> | |
| </a> | |
| </nav> | |
| <!-- Bottom links --> | |
| <div class="py-3 border-t border-gray-800 flex flex-col gap-0.5"> | |
| <a href="/trash" class="sidebar-link" data-page="trash"> | |
| <iconify-icon icon="ant-design:delete-outlined" class="icon-sm"></iconify-icon> | |
| <span data-i18n="nav_trash">{{ t('nav_trash') }}</span> | |
| <span id="trash-badge" class="ml-auto text-xs text-gray-500"></span> | |
| </a> | |
| </div> | |
| <!-- User menu --> | |
| <div class="border-t border-gray-800 px-3 py-2 relative" id="user-menu-container"> | |
| <button id="user-menu-btn" onclick="toggleUserMenu()" | |
| class="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg hover:bg-gray-800/50 transition-colors text-left"> | |
| <img src="/static/logo.png" alt="" class="w-7 h-7 rounded-full flex-shrink-0"> | |
| <div class="flex-1 min-w-0"> | |
| <div id="user-name-display" class="text-xs text-gray-300 truncate">{{ boot_user.username|default('') }}</div> | |
| <div id="user-role-display" class="text-xs text-gray-600">{{ boot_user.role|default('')|title }}</div> | |
| </div> | |
| <iconify-icon icon="ant-design:caret-up-outlined" id="user-caret" class="text-gray-600 text-xs transition-transform"></iconify-icon> | |
| </button> | |
| <!-- Dropdown --> | |
| <div id="user-dropdown" class="hidden absolute bottom-full left-3 right-3 mb-1 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden z-50"> | |
| <div class="px-3 py-2 border-b border-gray-700"> | |
| <div id="dd-username" class="text-xs text-gray-300 font-medium">{{ boot_user.username|default('') }}</div> | |
| <div id="dd-role" class="text-xs text-gray-500">{{ boot_user.role|default('')|title }}</div> | |
| </div> | |
| <a href="javascript:void(0)" class="block px-3 py-2 text-xs text-gray-400 hover:bg-gray-700 hover:text-gray-200 transition-colors"> | |
| <iconify-icon icon="ant-design:user-outlined" class="icon-sm mr-2"></iconify-icon><span data-i18n="menu_profile">{{ t('menu_profile') }}</span> | |
| </a> | |
| <a href="javascript:void(0)" class="block px-3 py-2 text-xs text-gray-400 hover:bg-gray-700 hover:text-gray-200 transition-colors"> | |
| <iconify-icon icon="ant-design:lock-outlined" class="icon-sm mr-2"></iconify-icon><span data-i18n="menu_change_password">{{ t('menu_change_password') }}</span> | |
| </a> | |
| <!-- 界面语言。两个选项都写自己的文字(English / 中文 是自名, | |
| 不随界面语言翻译),当前项由 updateLangToggle() 高亮。 | |
| 点这里不会关掉菜单:外部点击的判定用 container.contains(e.target), | |
| 而它在本容器内。 --> | |
| <div class="px-3 py-2 border-t border-gray-700"> | |
| <div class="mb-1.5 text-xs text-gray-500" data-i18n="language">{{ t('language') }}</div> | |
| <div class="flex gap-1"> | |
| <button type="button" data-lang-option="en" onclick="setLang('en')" | |
| aria-pressed="false" | |
| class="flex-1 rounded border border-gray-700 px-2 py-1 text-xs text-gray-400 hover:bg-gray-700 hover:text-gray-200 transition-colors">English</button> | |
| <button type="button" data-lang-option="zh" onclick="setLang('zh')" | |
| aria-pressed="false" | |
| class="flex-1 rounded border border-gray-700 px-2 py-1 text-xs text-gray-400 hover:bg-gray-700 hover:text-gray-200 transition-colors">中文</button> | |
| </div> | |
| </div> | |
| <a href="javascript:void(0)" onclick="handleLogout()" class="block px-3 py-2 text-xs text-red-400 hover:bg-gray-700 hover:text-red-300 transition-colors border-t border-gray-700"> | |
| <iconify-icon icon="ant-design:logout-outlined" class="icon-sm mr-2"></iconify-icon><span data-i18n="menu_logout">{{ t('menu_logout') }}</span> | |
| </a> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- Main content area --> | |
| <main class="flex-1 flex min-w-0 overflow-hidden"> | |
| {% block content %}{% endblock %} | |
| </main> | |
| <!-- Sidebar active state --> | |
| <script> | |
| // ── Active state (static links only) ────────── | |
| (function() { | |
| const pathname = document.location.pathname.replace(/\/$/, '') || '/'; | |
| // 同时遍历二级项 .sidebar-sub[data-page] —— 本块原本只认 | |
| // .sidebar-link,而「训练」挂在 sidebar-sub 上,不改这里它永远不会高亮。 | |
| // 带 [data-page] 限定是为了避开下面那批 review 状态快捷项:它们没有 | |
| // data-page,由紧跟着的另一段脚本按 URL 参数管。 | |
| document.querySelectorAll('.sidebar-link, .sidebar-sub[data-page]').forEach(link => { | |
| const linkPage = link.dataset.page; | |
| const isActive = (pathname === '/' && linkPage === 'home') | |
| || (pathname === '/tasks' && linkPage === 'tasks') | |
| || (pathname === '/workflow-studio' && linkPage === 'workflow-studio') | |
| || (pathname === '/users' && linkPage === 'users') | |
| || (pathname === '/annotation-studio' && linkPage === 'annotation-studio') | |
| || (pathname === '/review' && linkPage === 'review') | |
| || (pathname === '/trash' && linkPage === 'trash') | |
| // 一级项「模型」目前也指向 /train,所以它跟着一起亮 —— | |
| // 与 Video Review 在 /review 上「父项 + 某个状态子项同时亮」一致。 | |
| || (pathname === '/train' && linkPage === 'model'); | |
| if (isActive) link.classList.add('active'); | |
| }); | |
| })(); | |
| // ── Review status shortcut highlight (URL-driven) ── | |
| (function() { | |
| const pathname = document.location.pathname.replace(/\/$/, '') || '/'; | |
| if (pathname === '/review') { | |
| const status = new URLSearchParams(document.location.search).get('status') || 'completed'; | |
| const groupKey = status === 'reviewed' ? 'approved' | |
| : (status === 'failed' ? 'failed' : 'reviewing'); | |
| document.querySelectorAll('.sidebar-sub[data-review-status]').forEach(el => { | |
| if (el.dataset.reviewStatus === groupKey) el.classList.add('active'); | |
| }); | |
| } | |
| })(); | |
| // No-op stubs kept for app.js compatibility (the sidebar status tree | |
| // was removed — the hierarchy list lives on the review page itself). | |
| function refreshReviewTree() {} | |
| function highlightNavActive() {} | |
| window.highlightNavActive = highlightNavActive; | |
| function escHtml(s) { | |
| if (!s) return ''; | |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| // ── Trash badge ── | |
| async function updateTrashBadge() { | |
| try { | |
| const res = await fetch('/api/v1/episodes?status=deleted&limit=1'); | |
| const data = await res.json(); | |
| const badge = document.getElementById('trash-badge'); | |
| if (badge) { | |
| const count = data.total || 0; | |
| badge.textContent = count > 0 ? '(' + count + ')' : ''; | |
| badge.className = count > 0 ? 'text-orange-400 ml-auto text-xs' : 'text-gray-500 ml-auto text-xs'; | |
| } | |
| } catch(e) {} | |
| } | |
| updateTrashBadge(); | |
| // Bootstrap the shared layout from the signed-in token claims so the | |
| // real account is visible on first paint. /auth/me remains the | |
| // authoritative refresh for role changes and account expiry. | |
| window.__EGO_USER__ = {{ boot_user|default({})|tojson }}; | |
| // ── User menu ────────────────────────────────── | |
| let _userMenuLoaded = false; | |
| async function loadUserInfo() { | |
| if (_userMenuLoaded) return; | |
| _userMenuLoaded = true; | |
| try { | |
| const res = await fetch('/api/v1/auth/me'); | |
| if (!res.ok) return; | |
| const user = await res.json(); | |
| document.getElementById('user-name-display').textContent = user.username || '-'; | |
| document.getElementById('user-role-display').textContent = user.role || '-'; | |
| document.getElementById('dd-username').textContent = user.username || '-'; | |
| document.getElementById('dd-role').textContent = user.role || '-'; | |
| const usersNav = document.getElementById('users-nav'); | |
| if (usersNav && user.role === 'admin') usersNav.classList.remove('hidden'); | |
| } catch(e) { /* silent */ } | |
| } | |
| loadUserInfo(); | |
| function toggleUserMenu() { | |
| const dd = document.getElementById('user-dropdown'); | |
| const caret = document.getElementById('user-caret'); | |
| if (!dd) return; | |
| const isOpen = !dd.classList.contains('hidden'); | |
| dd.classList.toggle('hidden'); | |
| if (isOpen) { | |
| caret.style.transform = ''; | |
| } else { | |
| caret.style.transform = 'rotate(180deg)'; | |
| if (!_userMenuLoaded) loadUserInfo(); | |
| } | |
| } | |
| // Close dropdown when clicking outside | |
| document.addEventListener('click', function(e) { | |
| const container = document.getElementById('user-menu-container'); | |
| const dd = document.getElementById('user-dropdown'); | |
| if (container && dd && !container.contains(e.target)) { | |
| if (!dd.classList.contains('hidden')) { | |
| toggleUserMenu(); | |
| } | |
| } | |
| }); | |
| async function handleLogout() { | |
| try { | |
| await fetch('/api/v1/auth/logout', { method: 'POST' }); | |
| } catch(e) {} | |
| window.location.href = '/login'; | |
| } | |
| </script> | |
| </body> | |
| </html> | |