records.html 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800
  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>中后台系统</title>
  7. <meta name="description" content="Data Nexus 宽表数据视图控制台">
  8. <link rel="preconnect" href="https://fonts.googleapis.com">
  9. <link
  10. href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
  11. rel="stylesheet">
  12. <style>
  13. *,
  14. *::before,
  15. *::after {
  16. margin: 0;
  17. padding: 0;
  18. box-sizing: border-box;
  19. }
  20. :root {
  21. --bg-base: #f8fafc;
  22. --bg-sidebar: #ffffff;
  23. --bg-nav: #ffffff;
  24. --bg-card: #ffffff;
  25. --bg-hover: rgba(42, 139, 242, 0.05);
  26. --bg-active: rgba(42, 139, 242, 0.1);
  27. --border: #edf2f7;
  28. --border-dark: #e2e8f0;
  29. --text-primary: #1e293b;
  30. --text-secondary: #64748b;
  31. --text-muted: #94a3b8;
  32. --accent: #2a8bf2;
  33. --radius-sm: 6px;
  34. --radius-md: 12px;
  35. --radius-lg: 16px;
  36. --sidebar-w: 260px;
  37. --nav-h: 64px;
  38. --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
  39. --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
  40. }
  41. body {
  42. font-family: 'Outfit', 'Inter', -apple-system, sans-serif;
  43. background: var(--bg-base);
  44. color: var(--text-primary);
  45. height: 100vh;
  46. overflow: hidden;
  47. line-height: 1.6;
  48. }
  49. /* --- Global Layout --- */
  50. .app {
  51. display: flex;
  52. flex-direction: column;
  53. height: 100vh;
  54. }
  55. /* --- Top Navigation --- */
  56. .navbar {
  57. height: var(--nav-h);
  58. background: var(--bg-nav);
  59. border-bottom: 1px solid var(--border);
  60. display: flex;
  61. align-items: center;
  62. padding: 0 32px;
  63. z-index: 100;
  64. flex-shrink: 0;
  65. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
  66. }
  67. .nav-logo {
  68. font-size: 20px;
  69. font-weight: 800;
  70. color: #0f172a;
  71. margin-right: 48px;
  72. white-space: nowrap;
  73. letter-spacing: -0.5px;
  74. display: flex;
  75. align-items: center;
  76. gap: 10px;
  77. }
  78. .nav-logo::before {
  79. content: '';
  80. width: 32px;
  81. height: 32px;
  82. background: linear-gradient(135deg, var(--accent), #6366f1);
  83. border-radius: 8px;
  84. }
  85. .nav-menu {
  86. display: flex;
  87. height: 100%;
  88. align-items: center;
  89. }
  90. .nav-item {
  91. font-size: 15px;
  92. color: var(--accent);
  93. font-weight: 600;
  94. height: 100%;
  95. display: flex;
  96. align-items: center;
  97. padding: 0 16px;
  98. position: relative;
  99. }
  100. .nav-item::after {
  101. content: '';
  102. position: absolute;
  103. bottom: 0;
  104. left: 0;
  105. right: 0;
  106. height: 3px;
  107. background: var(--accent);
  108. border-radius: 3px 3px 0 0;
  109. }
  110. /* --- Main Content Layout --- */
  111. .main-container {
  112. flex: 1;
  113. display: grid;
  114. grid-template-columns: var(--sidebar-w) 1fr;
  115. overflow: hidden;
  116. }
  117. /* --- Sidebar --- */
  118. .sidebar {
  119. background: var(--bg-sidebar);
  120. border-right: 1px solid var(--border);
  121. overflow-y: auto;
  122. padding: 24px 12px;
  123. }
  124. .sidebar-title {
  125. font-size: 11px;
  126. font-weight: 700;
  127. color: var(--text-muted);
  128. padding: 8px 12px;
  129. text-transform: uppercase;
  130. letter-spacing: 1px;
  131. border-bottom: 1px solid var(--border);
  132. margin-bottom: 4px;
  133. }
  134. .sidebar-list {
  135. list-style: none;
  136. display: flex;
  137. flex-direction: column;
  138. gap: 4px;
  139. }
  140. .sidebar-item {
  141. padding: 12px 16px;
  142. font-size: 14px;
  143. color: var(--text-secondary);
  144. cursor: pointer;
  145. display: flex;
  146. align-items: center;
  147. gap: 12px;
  148. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  149. border-radius: var(--radius-md);
  150. border: none;
  151. font-weight: 500;
  152. }
  153. .sidebar-item .s-icon {
  154. width: 20px;
  155. height: 20px;
  156. opacity: 0.7;
  157. }
  158. .sidebar-item:hover {
  159. background: var(--bg-hover);
  160. color: var(--text-primary);
  161. transform: translateX(4px);
  162. }
  163. .sidebar-item.active {
  164. background: var(--bg-active);
  165. color: var(--accent);
  166. font-weight: 600;
  167. box-shadow: none;
  168. }
  169. .sidebar-item.active .s-icon {
  170. opacity: 1;
  171. color: var(--accent);
  172. }
  173. /* --- Content Area --- */
  174. .content {
  175. display: flex;
  176. flex-direction: column;
  177. overflow: hidden;
  178. padding: 24px 32px;
  179. }
  180. .table-container {
  181. flex: 1;
  182. overflow: auto;
  183. background: #ffffff;
  184. border-radius: var(--radius-lg);
  185. box-shadow: var(--shadow);
  186. border: 1px solid var(--border);
  187. }
  188. .records-table {
  189. width: 100%;
  190. border-collapse: separate;
  191. border-spacing: 0;
  192. min-width: 1000px;
  193. }
  194. .records-table thead th {
  195. background: #f8fafc;
  196. color: var(--text-secondary);
  197. font-size: 12px;
  198. font-weight: 700;
  199. text-transform: uppercase;
  200. letter-spacing: 0.5px;
  201. text-align: left;
  202. padding: 16px 20px;
  203. border-bottom: 1px solid var(--border);
  204. position: sticky;
  205. top: 0;
  206. z-index: 10;
  207. }
  208. .records-table td {
  209. padding: 20px;
  210. border-bottom: 1px solid var(--border);
  211. vertical-align: top;
  212. font-size: 14px;
  213. color: var(--text-primary);
  214. transition: background 0.15s;
  215. }
  216. .records-table tr:last-child td {
  217. border-bottom: none;
  218. }
  219. .records-table tr:hover td {
  220. background: #fcfdfe;
  221. }
  222. .id-col {
  223. width: 100px;
  224. }
  225. .commit-badge {
  226. font-family: 'JetBrains Mono', monospace;
  227. background: #f1f5f9;
  228. color: var(--text-secondary);
  229. padding: 4px 8px;
  230. border-radius: var(--radius-sm);
  231. font-size: 12px;
  232. display: inline-block;
  233. }
  234. .msg-col {
  235. width: 260px;
  236. font-weight: 600;
  237. color: #0f172a;
  238. }
  239. /* Bubble Tree Hierarchical Styles */
  240. .bubble-tree {
  241. background: #ffffff;
  242. border: 1px solid var(--border);
  243. border-radius: var(--radius-md);
  244. padding: 8px;
  245. min-height: 48px;
  246. min-width: 220px;
  247. box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
  248. }
  249. .fg-header {
  250. display: flex;
  251. align-items: center;
  252. gap: 4px;
  253. padding: 4px 8px;
  254. cursor: pointer;
  255. transition: background 0.1s;
  256. user-select: none;
  257. border-radius: 4px;
  258. }
  259. .fg-header:hover {
  260. background: var(--bg-hover);
  261. }
  262. .fg-name-wrap {
  263. display: flex;
  264. align-items: center;
  265. gap: 6px;
  266. min-width: 0;
  267. flex: 1;
  268. }
  269. .fg-arrow {
  270. width: 14px;
  271. height: 14px;
  272. color: var(--text-muted);
  273. transition: transform 0.2s;
  274. display: flex;
  275. align-items: center;
  276. justify-content: center;
  277. }
  278. .fg-arrow.open {
  279. transform: rotate(90deg);
  280. }
  281. .fg-icon {
  282. width: 16px;
  283. height: 16px;
  284. color: #f6ad55;
  285. display: flex;
  286. align-items: center;
  287. }
  288. .fg-name {
  289. font-size: 13px;
  290. color: var(--text-primary);
  291. font-weight: 500;
  292. white-space: nowrap;
  293. overflow: hidden;
  294. text-overflow: ellipsis;
  295. }
  296. .fg-count {
  297. font-size: 11px;
  298. color: var(--text-muted);
  299. background: #f1f5f9;
  300. padding: 1px 5px;
  301. border-radius: 3px;
  302. }
  303. .fg-children {
  304. display: none;
  305. }
  306. .fg-children.open {
  307. display: block;
  308. }
  309. .file-row {
  310. padding: 4px 8px;
  311. display: flex;
  312. align-items: center;
  313. gap: 8px;
  314. transition: background 0.1s;
  315. border-radius: 4px;
  316. }
  317. .file-row:hover {
  318. background: var(--bg-hover);
  319. }
  320. .f-icon {
  321. width: 16px;
  322. height: 16px;
  323. color: var(--text-muted);
  324. display: flex;
  325. align-items: center;
  326. }
  327. .f-info {
  328. flex: 1;
  329. min-width: 0;
  330. display: flex;
  331. flex-direction: column;
  332. }
  333. .f-name-line {
  334. display: flex;
  335. align-items: center;
  336. gap: 6px;
  337. }
  338. .f-name {
  339. font-size: 13px;
  340. color: var(--text-primary);
  341. white-space: nowrap;
  342. overflow: hidden;
  343. text-overflow: ellipsis;
  344. }
  345. .f-extracted {
  346. font-size: 11px;
  347. color: #3b82f6;
  348. word-break: break-all;
  349. margin-top: 1px;
  350. }
  351. .f-size {
  352. font-size: 11px;
  353. color: var(--text-muted);
  354. margin-left: auto;
  355. padding-left: 8px;
  356. }
  357. .btn-dl {
  358. color: var(--accent);
  359. text-decoration: none;
  360. display: flex;
  361. align-items: center;
  362. opacity: 0.6;
  363. transition: all 0.2s;
  364. margin-left: 4px;
  365. }
  366. .btn-dl:hover {
  367. opacity: 1;
  368. }
  369. .btn-dl svg {
  370. width: 14px;
  371. height: 14px;
  372. }
  373. .col-badge {
  374. display: inline-block;
  375. padding: 1px 6px;
  376. border-radius: 3px;
  377. font-size: 10px;
  378. font-weight: 700;
  379. margin-bottom: 4px;
  380. text-transform: uppercase;
  381. width: fit-content;
  382. }
  383. .badge-in {
  384. background: #eff6ff;
  385. color: #3b82f6;
  386. border: 1px solid #dbeafe;
  387. }
  388. .badge-out {
  389. background: #f0fdf4;
  390. color: #22c55e;
  391. border: 1px solid #dcfce7;
  392. }
  393. .th-cell {
  394. display: flex;
  395. flex-direction: column;
  396. justify-content: flex-start;
  397. }
  398. .th-label {
  399. font-size: 13px;
  400. }
  401. .state-box {
  402. display: flex;
  403. flex-direction: column;
  404. align-items: center;
  405. justify-content: center;
  406. height: 100%;
  407. color: var(--text-muted);
  408. }
  409. .spinner {
  410. width: 32px;
  411. height: 32px;
  412. border: 3px solid #e2e8f0;
  413. border-top-color: var(--accent);
  414. border-radius: 50%;
  415. animation: spin 0.8s linear infinite;
  416. margin-bottom: 16px;
  417. }
  418. @keyframes spin {
  419. to {
  420. transform: rotate(360deg);
  421. }
  422. }
  423. ::-webkit-scrollbar {
  424. width: 6px;
  425. height: 6px;
  426. }
  427. ::-webkit-scrollbar-track {
  428. background: transparent;
  429. }
  430. ::-webkit-scrollbar-thumb {
  431. background: #cbd5e1;
  432. border-radius: 3px;
  433. }
  434. </style>
  435. </head>
  436. <body>
  437. <div class="app">
  438. <header class="navbar">
  439. <div class="nav-logo">中后台系统</div>
  440. <nav class="nav-menu">
  441. <div class="nav-item">解构</div>
  442. </nav>
  443. </header>
  444. <div class="main-container">
  445. <aside class="sidebar">
  446. <div id="sidebarContent">
  447. <ul class="sidebar-list" id="stageList"></ul>
  448. </div>
  449. </aside>
  450. <main class="content">
  451. <div class="table-container" id="contentBody">
  452. <div class="state-box">
  453. <div class="spinner"></div>
  454. <p>加载中...</p>
  455. </div>
  456. </div>
  457. </main>
  458. </div>
  459. </div>
  460. <script>
  461. const IC = {
  462. file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>',
  463. folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>',
  464. download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>',
  465. chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>',
  466. // Stage icons
  467. topic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>',
  468. what: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>',
  469. craft: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"></path><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path><path d="M2 2l7.5 1.5"></path><path d="M14 11l5 5"></path></svg>',
  470. gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>',
  471. };
  472. const PAGE_SIZE = 20;
  473. let S = { stages: [], stageProjectMap: {}, stage: null, records: [], skip: 0, hasMore: true, loading: false };
  474. const $ = id => document.getElementById(id);
  475. function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
  476. function fmtSize(b) {
  477. if (!b && b !== 0) return '';
  478. const u = ['B', 'KB', 'MB', 'GB']; let i = 0, s = b;
  479. while (s >= 1024 && i < u.length - 1) { s /= 1024; i++; }
  480. return s.toFixed(i > 0 ? 1 : 0) + ' ' + u[i];
  481. }
  482. async function api(url) {
  483. try {
  484. const r = await fetch(url);
  485. if (!r.ok) {
  486. const text = await r.text();
  487. throw new Error(`HTTP ${r.status}: ${text || r.statusText}`);
  488. }
  489. return r.json();
  490. } catch (e) {
  491. console.error('API Error:', e);
  492. throw e;
  493. }
  494. }
  495. async function init() {
  496. try {
  497. S.stages = await api('/stages/all');
  498. S.stages.forEach(st => { S.stageProjectMap[st.name] = st.project_id; });
  499. renderSidebar();
  500. const first = document.querySelector('.sidebar-item');
  501. if (first) first.click();
  502. } catch (e) {
  503. $('contentBody').innerHTML = `<div class="state-box"><p style="color:red">初始化失败: ${esc(e.message)}</p></div>`;
  504. }
  505. }
  506. function renderSidebar() {
  507. const list = $('stageList');
  508. list.innerHTML = '';
  509. const STAGE_ORDER = ['how选题', 'what选题', 'what创作', 'what制作'];
  510. const STAGE_ICONS = {
  511. 'how选题': IC.topic,
  512. 'what选题': IC.what,
  513. 'what创作': IC.craft,
  514. 'what制作': IC.gear
  515. };
  516. const sortedStages = [...S.stages].sort((a, b) => {
  517. const idxA = STAGE_ORDER.indexOf(a.name);
  518. const idxB = STAGE_ORDER.indexOf(b.name);
  519. if (idxA !== -1 && idxB !== -1) return idxA - idxB;
  520. if (idxA !== -1) return -1;
  521. if (idxB !== -1) return 1;
  522. return a.name.localeCompare(b.name);
  523. });
  524. sortedStages.forEach(st => {
  525. const li = document.createElement('li');
  526. li.className = 'sidebar-item';
  527. const icon = STAGE_ICONS[st.name] || IC.file;
  528. li.innerHTML = `<span class="s-icon">${icon}</span>${esc(st.name)}`;
  529. li.onclick = () => selectStage(li, st.name);
  530. list.appendChild(li);
  531. });
  532. }
  533. async function selectStage(el, stageName) {
  534. document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active'));
  535. el.classList.add('active');
  536. S.stage = stageName; S.records = []; S.skip = 0;
  537. loadRecords();
  538. }
  539. async function loadRecords() {
  540. if (S.loading) return;
  541. S.loading = true;
  542. $('contentBody').innerHTML = '<div class="state-box"><div class="spinner"></div><p>读取流水线数据...</p></div>';
  543. try {
  544. const pid = S.stageProjectMap[S.stage];
  545. if (!pid) throw new Error('项目 ID 不存在');
  546. const url = `/projects/${pid}/records?stage=${encodeURIComponent(S.stage)}&skip=0&limit=${PAGE_SIZE}`;
  547. const data = await api(url);
  548. S.records = Array.isArray(data) ? data : [];
  549. renderTable();
  550. } catch (e) {
  551. console.error('Load Records Error:', e);
  552. $('contentBody').innerHTML = `<div class="state-box"><p style="color:red">加载失败: ${esc(e.message)}</p></div>`;
  553. }
  554. S.loading = false;
  555. }
  556. function renderTable() {
  557. if (!S.records || !S.records.length) {
  558. $('contentBody').innerHTML = '<div class="state-box"><h2>暂无记录</h2></div>';
  559. return;
  560. }
  561. const inLabels = new Set(), outLabels = new Set();
  562. S.records.forEach(r => {
  563. if (r.inputs) r.inputs.forEach(f => inLabels.add(f.label || '输入'));
  564. if (r.outputs) r.outputs.forEach(f => outLabels.add(f.label || '输出'));
  565. });
  566. const sortedIn = Array.from(inLabels).sort();
  567. const sortedOut = Array.from(outLabels).sort();
  568. let h = `<table class="records-table">
  569. <thead>
  570. <tr>
  571. <th class="id-col">commit id</th>
  572. <th class="msg-col">commit message</th>`;
  573. sortedIn.forEach(l => h += `<th><div class="th-cell"><span class="col-badge badge-in">输入</span><span class="th-label">${esc(l)}</span></div></th>`);
  574. sortedOut.forEach(l => h += `<th><div class="th-cell"><span class="col-badge badge-out">输出</span><span class="th-label">${esc(l)}</span></div></th>`);
  575. h += `</tr></thead><tbody>`;
  576. S.records.forEach(r => {
  577. h += `<tr>
  578. <td class="id-col"><span class="commit-badge">${esc(r.commit_id.substring(0, 8))}</span></td>
  579. <td class="msg-col">${esc(r.commit_message || '无描述')}</td>`;
  580. sortedIn.forEach(l => {
  581. const files = (r.inputs || []).filter(f => (f.label || '输入') === l);
  582. h += `<td>${renderFiles(files)}</td>`;
  583. });
  584. sortedOut.forEach(l => {
  585. const files = (r.outputs || []).filter(f => (f.label || '输出') === l);
  586. h += `<td>${renderPlainFiles(files)}</td>`;
  587. });
  588. h += `</tr>`;
  589. });
  590. h += `</tbody></table>`;
  591. $('contentBody').innerHTML = h;
  592. }
  593. function buildFileTree(files) {
  594. const root = { dirs: {}, files: [], path: '' };
  595. if (!files || !files.length) return root;
  596. files.forEach(f => {
  597. const parts = (f.relative_path || '').split('/');
  598. let cur = root;
  599. for (let i = 0; i < parts.length - 1; i++) {
  600. const p = parts[i];
  601. if (!cur.dirs[p]) {
  602. const curPath = cur.path ? cur.path + '/' + p : p;
  603. cur.dirs[p] = { name: p, path: curPath, dirs: {}, files: [] };
  604. }
  605. cur = cur.dirs[p];
  606. }
  607. cur.files.push(f);
  608. });
  609. function compact(node) {
  610. Object.keys(node.dirs).forEach(k => compact(node.dirs[k]));
  611. Object.keys(node.dirs).forEach(k => {
  612. let child = node.dirs[k];
  613. if (!child) return;
  614. let changed = true;
  615. while (changed) {
  616. changed = false;
  617. if (Object.keys(child.dirs).length === 1 && child.files.length === 0) {
  618. const onlyChildKey = Object.keys(child.dirs)[0];
  619. const onlyChild = child.dirs[onlyChildKey];
  620. child.name = child.name + '/' + onlyChild.name;
  621. child.path = onlyChild.path;
  622. child.dirs = onlyChild.dirs;
  623. child.files = onlyChild.files;
  624. changed = true;
  625. }
  626. }
  627. });
  628. }
  629. compact(root);
  630. return root;
  631. }
  632. function countFiles(node) {
  633. let cnt = node.files.length;
  634. Object.values(node.dirs).forEach(d => { cnt += countFiles(d); });
  635. return cnt;
  636. }
  637. function renderFileItem(f, depth = 0) {
  638. const name = f.relative_path ? f.relative_path.split('/').pop() : '未知文件';
  639. const padding = `padding-left: ${(depth === 0 ? 8 : 24 + (depth - 1) * 16)}px;`;
  640. return `
  641. <div class="file-row" style="${padding}">
  642. <span class="f-icon">${IC.file}</span>
  643. <div class="f-info">
  644. <div class="f-name-line">
  645. <span class="f-name" title="${esc(f.relative_path)}">${esc(name)}</span>
  646. <span class="f-size">${fmtSize(f.file_size)}</span>
  647. <a class="btn-dl" href="/files/${f.id}/content" download title="下载">${IC.download}</a>
  648. </div>
  649. ${f.extracted_value ? `<div class="f-extracted">↳ ${esc(f.extracted_value)}</div>` : ''}
  650. </div>
  651. </div>`;
  652. }
  653. function renderTree(node, depth) {
  654. let h = '';
  655. const dirKeys = Object.keys(node.dirs).sort((a, b) => a.localeCompare(b));
  656. dirKeys.forEach(k => {
  657. const d = node.dirs[k];
  658. const gid = 'fg_' + Math.random().toString(36).substr(2, 6);
  659. const fileCount = countFiles(d);
  660. const padding = `padding-left: ${depth * 16}px;`;
  661. h += `
  662. <div class="fg-header" style="${padding}" onclick="toggleFG('${gid}')">
  663. <div class="fg-name-wrap">
  664. <span class="fg-arrow" id="fa_${gid}">${IC.chevron}</span>
  665. <span class="fg-icon">${IC.folder}</span>
  666. <span class="fg-name" title="${esc(d.path)}">${esc(d.name)}/</span>
  667. <span class="fg-count">${fileCount}</span>
  668. </div>
  669. </div>
  670. <div class="fg-children" id="${gid}">
  671. ${renderTree(d, depth + 1)}
  672. </div>`;
  673. });
  674. node.files.sort((a, b) => (a.relative_path || '').localeCompare(b.relative_path || '')).forEach(f => {
  675. h += renderFileItem(f, depth);
  676. });
  677. return h;
  678. }
  679. function toggleFG(id) {
  680. const ch = $(id), ar = $('fa_' + id);
  681. if (ch) ch.classList.toggle('open');
  682. if (ar) ar.classList.toggle('open');
  683. }
  684. function renderFiles(files) {
  685. if (!files || !files.length) return '-';
  686. // 如果只有一个文件,不再使用文件夹树,直接展示文件
  687. if (files.length === 1) {
  688. return renderPlainFiles(files);
  689. }
  690. const tree = buildFileTree(files);
  691. return `<div class="bubble-tree">${renderTree(tree, 0)}</div>`;
  692. }
  693. function renderPlainFiles(files) {
  694. if (!files || !files.length) return '-';
  695. // Flat list for outputs, slightly cleaner padding
  696. return `<div class="bubble-tree" style="min-width: 180px;">${files.map(f => renderFileItem(f, 0)).join('')}</div>`;
  697. }
  698. init();
  699. </script>
  700. </body>
  701. </html>