Spaces:
Running
Running
File size: 20,937 Bytes
bb59211 d646dd7 95f5290 bb59211 849e0ae bb59211 d646dd7 bb59211 d646dd7 bb59211 d646dd7 bb59211 d646dd7 bb59211 d646dd7 bb59211 d646dd7 bb59211 d646dd7 bb59211 d646dd7 bb59211 d646dd7 bb59211 d646dd7 bb59211 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | <!DOCTYPE html>
<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 !important; }
.plyr__control--overlaid { display: inline-flex !important; }
.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 !important; }
/* 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 !important; }
.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>
|