records.html 23 KB

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