index.html 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Data Nexus - 数据中台</title>
  7. <meta name="description" content="Data Nexus 轻量级数据中台,浏览和下载项目数据文件">
  8. <style>
  9. *,
  10. *::before,
  11. *::after {
  12. margin: 0;
  13. padding: 0;
  14. box-sizing: border-box;
  15. }
  16. :root {
  17. --bg-primary: #0f172a;
  18. --bg-surface: #1e293b;
  19. --bg-hover: #334155;
  20. --bg-card: rgba(30, 41, 59, 0.7);
  21. --border: rgba(148, 163, 184, 0.1);
  22. --border-hover: rgba(59, 130, 246, 0.4);
  23. --text-primary: #f1f5f9;
  24. --text-secondary: #94a3b8;
  25. --text-muted: #64748b;
  26. --accent: #3b82f6;
  27. --accent-light: #60a5fa;
  28. --accent-glow: rgba(59, 130, 246, 0.15);
  29. --green: #22c55e;
  30. --orange: #f59e0b;
  31. --purple: #a78bfa;
  32. --radius: 10px;
  33. }
  34. body {
  35. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  36. background: var(--bg-primary);
  37. color: var(--text-primary);
  38. min-height: 100vh;
  39. line-height: 1.6;
  40. }
  41. /* Header */
  42. .header {
  43. background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9));
  44. border-bottom: 1px solid var(--border);
  45. backdrop-filter: blur(20px);
  46. position: sticky;
  47. top: 0;
  48. z-index: 100;
  49. }
  50. .header-inner {
  51. max-width: 1200px;
  52. margin: 0 auto;
  53. padding: 16px 24px;
  54. }
  55. .header-top {
  56. display: flex;
  57. align-items: center;
  58. gap: 12px;
  59. margin-bottom: 12px;
  60. }
  61. .logo-icon {
  62. width: 32px;
  63. height: 32px;
  64. color: var(--accent);
  65. }
  66. .header-top h1 {
  67. font-size: 20px;
  68. font-weight: 700;
  69. background: linear-gradient(135deg, var(--accent-light), var(--purple));
  70. -webkit-background-clip: text;
  71. background-clip: text;
  72. -webkit-text-fill-color: transparent;
  73. }
  74. /* Breadcrumb */
  75. .breadcrumb {
  76. display: flex;
  77. align-items: center;
  78. gap: 4px;
  79. flex-wrap: wrap;
  80. font-size: 14px;
  81. }
  82. .breadcrumb-item {
  83. color: var(--text-muted);
  84. cursor: pointer;
  85. padding: 2px 8px;
  86. border-radius: 4px;
  87. transition: all 0.2s;
  88. }
  89. .breadcrumb-item:hover {
  90. color: var(--accent-light);
  91. background: var(--accent-glow);
  92. }
  93. .breadcrumb-item.active {
  94. color: var(--text-primary);
  95. cursor: default;
  96. }
  97. .breadcrumb-item.active:hover {
  98. background: none;
  99. color: var(--text-primary);
  100. }
  101. .breadcrumb-sep {
  102. color: var(--text-muted);
  103. font-size: 12px;
  104. }
  105. .breadcrumb-sep svg {
  106. width: 14px;
  107. height: 14px;
  108. vertical-align: middle;
  109. }
  110. /* Main content */
  111. .main {
  112. max-width: 1200px;
  113. margin: 0 auto;
  114. padding: 24px;
  115. }
  116. /* Item list */
  117. .item-list {
  118. background: var(--bg-card);
  119. border: 1px solid var(--border);
  120. border-radius: var(--radius);
  121. overflow: hidden;
  122. backdrop-filter: blur(10px);
  123. }
  124. .item {
  125. display: flex;
  126. align-items: center;
  127. gap: 14px;
  128. padding: 14px 20px;
  129. border-bottom: 1px solid var(--border);
  130. cursor: pointer;
  131. transition: all 0.2s ease;
  132. animation: fadeSlideIn 0.3s ease forwards;
  133. opacity: 0;
  134. }
  135. .item:last-child {
  136. border-bottom: none;
  137. }
  138. .item:hover {
  139. background: var(--bg-hover);
  140. border-color: var(--border-hover);
  141. }
  142. .item-icon {
  143. flex-shrink: 0;
  144. width: 20px;
  145. height: 20px;
  146. }
  147. .item-icon svg {
  148. width: 20px;
  149. height: 20px;
  150. }
  151. .item-icon.project {
  152. color: var(--accent-light);
  153. }
  154. .item-icon.stage {
  155. color: var(--orange);
  156. }
  157. .item-icon.commit {
  158. color: var(--green);
  159. }
  160. .item-icon.file {
  161. color: var(--text-muted);
  162. }
  163. .item-icon.folder {
  164. color: var(--orange);
  165. }
  166. .item-body {
  167. flex: 1;
  168. min-width: 0;
  169. }
  170. .item-name {
  171. font-size: 15px;
  172. font-weight: 500;
  173. color: var(--text-primary);
  174. white-space: nowrap;
  175. overflow: hidden;
  176. text-overflow: ellipsis;
  177. }
  178. .item-desc {
  179. font-size: 12px;
  180. color: var(--text-muted);
  181. margin-top: 2px;
  182. white-space: nowrap;
  183. overflow: hidden;
  184. text-overflow: ellipsis;
  185. }
  186. .item-meta {
  187. flex-shrink: 0;
  188. text-align: right;
  189. font-size: 12px;
  190. color: var(--text-muted);
  191. }
  192. .item-meta .meta-main {
  193. color: var(--text-secondary);
  194. font-size: 13px;
  195. }
  196. .item-arrow {
  197. flex-shrink: 0;
  198. color: var(--text-muted);
  199. transition: transform 0.2s;
  200. }
  201. .item:hover .item-arrow {
  202. color: var(--accent-light);
  203. transform: translateX(3px);
  204. }
  205. .item-arrow svg {
  206. width: 16px;
  207. height: 16px;
  208. }
  209. /* Download button */
  210. .btn-download {
  211. display: inline-flex;
  212. align-items: center;
  213. gap: 6px;
  214. padding: 6px 14px;
  215. border-radius: 6px;
  216. border: none;
  217. background: var(--accent);
  218. color: white;
  219. font-size: 12px;
  220. font-weight: 500;
  221. cursor: pointer;
  222. transition: all 0.2s;
  223. text-decoration: none;
  224. }
  225. .btn-download:hover {
  226. background: var(--accent-light);
  227. transform: translateY(-1px);
  228. }
  229. .btn-download svg {
  230. width: 14px;
  231. height: 14px;
  232. }
  233. /* File tree */
  234. .file-tree {
  235. padding: 8px 0;
  236. }
  237. .tree-item {
  238. display: flex;
  239. align-items: center;
  240. gap: 10px;
  241. padding: 10px 20px;
  242. border-bottom: 1px solid var(--border);
  243. transition: background 0.15s;
  244. animation: fadeSlideIn 0.3s ease forwards;
  245. opacity: 0;
  246. }
  247. .tree-item:last-child {
  248. border-bottom: none;
  249. }
  250. .tree-item:hover {
  251. background: var(--bg-hover);
  252. }
  253. .tree-item .indent {
  254. flex-shrink: 0;
  255. }
  256. .tree-item .item-icon {
  257. flex-shrink: 0;
  258. }
  259. .tree-item .item-body {
  260. flex: 1;
  261. min-width: 0;
  262. }
  263. .tree-item .item-name {
  264. font-size: 14px;
  265. }
  266. .tree-folder-toggle {
  267. cursor: pointer;
  268. color: var(--text-muted);
  269. width: 16px;
  270. height: 16px;
  271. flex-shrink: 0;
  272. transition: transform 0.2s;
  273. }
  274. .tree-folder-toggle.expanded {
  275. transform: rotate(90deg);
  276. }
  277. .tree-children {
  278. display: none;
  279. }
  280. .tree-children.expanded {
  281. display: block;
  282. }
  283. /* Status badges */
  284. .badge {
  285. display: inline-block;
  286. padding: 2px 8px;
  287. border-radius: 4px;
  288. font-size: 11px;
  289. font-weight: 600;
  290. }
  291. .badge-stage {
  292. background: rgba(245, 158, 11, 0.15);
  293. color: var(--orange);
  294. border: 1px solid rgba(245, 158, 11, 0.25);
  295. }
  296. .badge-count {
  297. background: rgba(59, 130, 246, 0.15);
  298. color: var(--accent-light);
  299. border: 1px solid rgba(59, 130, 246, 0.2);
  300. }
  301. /* Loading & Empty states */
  302. .state-container {
  303. display: flex;
  304. flex-direction: column;
  305. align-items: center;
  306. justify-content: center;
  307. padding: 80px 20px;
  308. color: var(--text-muted);
  309. }
  310. .state-container svg {
  311. width: 48px;
  312. height: 48px;
  313. margin-bottom: 16px;
  314. opacity: 0.5;
  315. }
  316. .state-container p {
  317. font-size: 15px;
  318. }
  319. .spinner {
  320. width: 32px;
  321. height: 32px;
  322. border: 3px solid var(--border);
  323. border-top-color: var(--accent);
  324. border-radius: 50%;
  325. animation: spin 0.8s linear infinite;
  326. margin-bottom: 16px;
  327. }
  328. /* Section header */
  329. .section-header {
  330. padding: 12px 20px;
  331. background: rgba(15, 23, 42, 0.5);
  332. border-bottom: 1px solid var(--border);
  333. font-size: 12px;
  334. color: var(--text-muted);
  335. font-weight: 600;
  336. text-transform: uppercase;
  337. letter-spacing: 0.5px;
  338. }
  339. /* Animations */
  340. @keyframes fadeSlideIn {
  341. from {
  342. opacity: 0;
  343. transform: translateY(8px);
  344. }
  345. to {
  346. opacity: 1;
  347. transform: translateY(0);
  348. }
  349. }
  350. @keyframes spin {
  351. to {
  352. transform: rotate(360deg);
  353. }
  354. }
  355. /* Responsive */
  356. @media (max-width: 640px) {
  357. .header-inner,
  358. .main {
  359. padding-left: 16px;
  360. padding-right: 16px;
  361. }
  362. .item {
  363. padding: 12px 14px;
  364. gap: 10px;
  365. }
  366. .item-meta {
  367. display: none;
  368. }
  369. }
  370. </style>
  371. </head>
  372. <body>
  373. <header class="header">
  374. <div class="header-inner">
  375. <div class="header-top">
  376. <svg class="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  377. <path
  378. d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z" />
  379. <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
  380. <line x1="12" y1="22.08" x2="12" y2="12" />
  381. </svg>
  382. <h1>Data Nexus</h1>
  383. </div>
  384. <nav id="breadcrumb" class="breadcrumb"></nav>
  385. </div>
  386. </header>
  387. <main class="main">
  388. <div id="content"></div>
  389. </main>
  390. <script>
  391. // ============ SVG Icons ============
  392. const SVG = {
  393. project: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
  394. folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>',
  395. commit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><line x1="1.05" y1="12" x2="7" y2="12"/><line x1="17.01" y1="12" x2="22.96" y2="12"/></svg>',
  396. file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
  397. download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
  398. chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>',
  399. home: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
  400. empty: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><path d="M13 2v7h7"/></svg>',
  401. };
  402. const chevronSep = `<span class="breadcrumb-sep">${SVG.chevron}</span>`;
  403. // ============ State ============
  404. let state = {
  405. project: null,
  406. stage: null,
  407. version: null,
  408. versionsCache: [], // all versions for current project
  409. };
  410. const $content = document.getElementById('content');
  411. const $breadcrumb = document.getElementById('breadcrumb');
  412. // ============ Utils ============
  413. function formatSize(bytes) {
  414. if (!bytes && bytes !== 0) return '-';
  415. const u = ['B', 'KB', 'MB', 'GB', 'TB'];
  416. let i = 0, s = bytes;
  417. while (s >= 1024 && i < u.length - 1) { s /= 1024; i++; }
  418. return s.toFixed(i > 0 ? 1 : 0) + ' ' + u[i];
  419. }
  420. function formatTime(iso) {
  421. if (!iso) return '';
  422. const d = new Date(iso);
  423. const pad = n => String(n).padStart(2, '0');
  424. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
  425. }
  426. function relativeTime(iso) {
  427. if (!iso) return '';
  428. const diff = Date.now() - new Date(iso).getTime();
  429. const m = Math.floor(diff / 60000);
  430. if (m < 1) return '刚刚';
  431. if (m < 60) return m + ' 分钟前';
  432. const h = Math.floor(m / 60);
  433. if (h < 24) return h + ' 小时前';
  434. const d = Math.floor(h / 24);
  435. if (d < 30) return d + ' 天前';
  436. return formatTime(iso);
  437. }
  438. function staggerDelay(i) {
  439. return `animation-delay: ${i * 0.04}s;`;
  440. }
  441. async function api(path) {
  442. const res = await fetch(path);
  443. if (!res.ok) throw new Error(`API Error: ${res.status}`);
  444. return res.json();
  445. }
  446. // ============ Rendering ============
  447. function renderLoading() {
  448. $content.innerHTML = `<div class="item-list"><div class="state-container"><div class="spinner"></div><p>加载中...</p></div></div>`;
  449. }
  450. function renderEmpty(msg) {
  451. $content.innerHTML = `<div class="item-list"><div class="state-container">${SVG.empty}<p>${msg}</p></div></div>`;
  452. }
  453. function renderError(msg) {
  454. $content.innerHTML = `<div class="item-list"><div class="state-container"><p style="color:#ef4444;">⚠ ${msg}</p><p style="margin-top:8px;font-size:13px;cursor:pointer;color:var(--accent);" onclick="loadProjects()">点击重试</p></div></div>`;
  455. }
  456. // ============ Breadcrumb ============
  457. function updateBreadcrumb() {
  458. let html = `<span class="breadcrumb-item ${!state.project ? 'active' : ''}" onclick="loadProjects()">所有项目</span>`;
  459. if (state.project) {
  460. html += chevronSep + `<span class="breadcrumb-item ${!state.stage ? 'active' : ''}" onclick="selectProject('${state.project.id}')">${state.project.project_name}</span>`;
  461. }
  462. if (state.stage) {
  463. html += chevronSep + `<span class="breadcrumb-item ${!state.version ? 'active' : ''}" onclick="selectStage('${state.stage}')">${state.stage}</span>`;
  464. }
  465. if (state.version) {
  466. html += chevronSep + `<span class="breadcrumb-item active">${state.version.commit_id.substring(0, 8)}</span>`;
  467. }
  468. $breadcrumb.innerHTML = html;
  469. }
  470. // ============ Level 1: Projects ============
  471. async function loadProjects() {
  472. state = { project: null, stage: null, version: null, versionsCache: [] };
  473. updateBreadcrumb();
  474. renderLoading();
  475. try {
  476. const projects = await api('/projects?limit=500');
  477. if (!projects.length) return renderEmpty('暂无项目');
  478. let html = '<div class="item-list"><div class="section-header">项目列表</div>';
  479. projects.forEach((p, i) => {
  480. html += `<div class="item" style="${staggerDelay(i)}" onclick="selectProject('${p.id}')">
  481. <div class="item-icon project">${SVG.project}</div>
  482. <div class="item-body">
  483. <div class="item-name">${esc(p.project_name)}</div>
  484. ${p.description ? `<div class="item-desc">${esc(p.description)}</div>` : ''}
  485. </div>
  486. <div class="item-meta"><div class="meta-main">${relativeTime(p.created_at)}</div><div>${formatTime(p.created_at)}</div></div>
  487. <div class="item-arrow">${SVG.chevron}</div>
  488. </div>`;
  489. });
  490. html += '</div>';
  491. $content.innerHTML = html;
  492. } catch (e) { renderError('加载项目失败: ' + e.message); }
  493. }
  494. // ============ Level 2: Stages ============
  495. async function selectProject(projectId) {
  496. renderLoading();
  497. try {
  498. const projects = await api('/projects?limit=500');
  499. state.project = projects.find(p => p.id === projectId);
  500. state.stage = null; state.version = null;
  501. const versions = await api(`/projects/${projectId}/versions?limit=5000`);
  502. state.versionsCache = versions;
  503. updateBreadcrumb();
  504. // Extract unique stages with stats
  505. const stageMap = {};
  506. versions.forEach(v => {
  507. if (!stageMap[v.stage]) stageMap[v.stage] = { count: 0, latest: v.created_at };
  508. stageMap[v.stage].count++;
  509. if (new Date(v.created_at) > new Date(stageMap[v.stage].latest))
  510. stageMap[v.stage].latest = v.created_at;
  511. });
  512. const stages = Object.entries(stageMap).sort((a, b) => a[0].localeCompare(b[0]));
  513. if (!stages.length) return renderEmpty('该项目暂无数据');
  514. let html = '<div class="item-list"><div class="section-header">数据阶段</div>';
  515. stages.forEach(([name, info], i) => {
  516. html += `<div class="item" style="${staggerDelay(i)}" onclick="selectStage('${esc(name)}')">
  517. <div class="item-icon stage">${SVG.folder}</div>
  518. <div class="item-body">
  519. <div class="item-name">${esc(name)}</div>
  520. <div class="item-desc"><span class="badge badge-count">${info.count} 次提交</span></div>
  521. </div>
  522. <div class="item-meta"><div class="meta-main">最近更新</div><div>${relativeTime(info.latest)}</div></div>
  523. <div class="item-arrow">${SVG.chevron}</div>
  524. </div>`;
  525. });
  526. html += '</div>';
  527. $content.innerHTML = html;
  528. } catch (e) { renderError('加载阶段失败: ' + e.message); }
  529. }
  530. // ============ Level 3: Commits/Versions ============
  531. function selectStage(stageName) {
  532. state.stage = stageName; state.version = null;
  533. updateBreadcrumb();
  534. const versions = state.versionsCache
  535. .filter(v => v.stage === stageName)
  536. .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
  537. if (!versions.length) return renderEmpty('该阶段暂无提交');
  538. let html = '<div class="item-list"><div class="section-header">提交记录</div>';
  539. versions.forEach((v, i) => {
  540. const shortId = v.commit_id.substring(0, 8);
  541. html += `<div class="item" style="${staggerDelay(i)}" onclick="selectVersion('${v.id}')">
  542. <div class="item-icon commit">${SVG.commit}</div>
  543. <div class="item-body">
  544. <div class="item-name"><code style="font-size:13px;color:var(--accent-light);background:var(--accent-glow);padding:2px 6px;border-radius:4px;">${shortId}</code></div>
  545. <div class="item-desc">${v.author ? '作者: ' + esc(v.author) : ''}</div>
  546. </div>
  547. <div class="item-meta"><div class="meta-main">${relativeTime(v.created_at)}</div><div>${formatTime(v.created_at)}</div></div>
  548. <div class="item-arrow">${SVG.chevron}</div>
  549. </div>`;
  550. });
  551. html += '</div>';
  552. $content.innerHTML = html;
  553. }
  554. // ============ Level 4: Files ============
  555. async function selectVersion(versionId) {
  556. renderLoading();
  557. try {
  558. const v = state.versionsCache.find(x => x.id === versionId);
  559. if (v) state.version = v;
  560. updateBreadcrumb();
  561. const tree = await api(`/versions/${versionId}/files`);
  562. if (!tree.length) return renderEmpty('该提交暂无文件');
  563. let html = '<div class="item-list"><div class="section-header">文件列表</div><div class="file-tree">';
  564. html += renderTree(tree, 0);
  565. html += '</div></div>';
  566. $content.innerHTML = html;
  567. } catch (e) { renderError('加载文件失败: ' + e.message); }
  568. }
  569. function renderTree(nodes, depth) {
  570. let html = '';
  571. let idx = 0;
  572. nodes.forEach(node => {
  573. const indent = `<span class="indent" style="width:${depth * 24}px;display:inline-block;"></span>`;
  574. if (node.type === 'folder') {
  575. const folderId = 'f_' + Math.random().toString(36).substr(2, 8);
  576. html += `<div class="tree-item" style="${staggerDelay(idx++)}" onclick="toggleFolder('${folderId}', this)">
  577. ${indent}
  578. <span class="tree-folder-toggle" id="toggle_${folderId}">${SVG.chevron}</span>
  579. <div class="item-icon folder">${SVG.folder}</div>
  580. <div class="item-body"><div class="item-name">${esc(node.name)}</div></div>
  581. </div>
  582. <div class="tree-children" id="${folderId}">
  583. ${renderTree(node.children || [], depth + 1)}
  584. </div>`;
  585. } else {
  586. html += `<div class="tree-item" style="${staggerDelay(idx++)}">
  587. ${indent}
  588. <span style="width:16px;display:inline-block;"></span>
  589. <div class="item-icon file">${SVG.file}</div>
  590. <div class="item-body">
  591. <div class="item-name">${esc(node.name)}</div>
  592. <div class="item-desc">${formatSize(node.size)}${node.file_type ? ' · ' + node.file_type : ''}</div>
  593. </div>
  594. <a class="btn-download" href="/files/${node.id}/content" download="${esc(node.name)}" onclick="event.stopPropagation();">
  595. ${SVG.download} 下载
  596. </a>
  597. </div>`;
  598. }
  599. });
  600. return html;
  601. }
  602. function toggleFolder(id, el) {
  603. const children = document.getElementById(id);
  604. const toggle = document.getElementById('toggle_' + id);
  605. if (children) children.classList.toggle('expanded');
  606. if (toggle) toggle.classList.toggle('expanded');
  607. }
  608. function esc(s) {
  609. if (!s) return '';
  610. const d = document.createElement('div');
  611. d.textContent = s;
  612. return d.innerHTML;
  613. }
  614. // ============ Init ============
  615. loadProjects();
  616. </script>
  617. </body>
  618. </html>