|
|
@@ -0,0 +1,730 @@
|
|
|
+<!DOCTYPE html>
|
|
|
+<html lang="zh-CN">
|
|
|
+
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8">
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
+ <title>中后台系统</title>
|
|
|
+ <meta name="description" content="Data Nexus 宽表数据视图控制台">
|
|
|
+ <link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
+ <link
|
|
|
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
|
|
+ rel="stylesheet">
|
|
|
+ <style>
|
|
|
+ *,
|
|
|
+ *::before,
|
|
|
+ *::after {
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+ }
|
|
|
+
|
|
|
+ :root {
|
|
|
+ --bg-base: #f4f7f9;
|
|
|
+ --bg-sidebar: #ffffff;
|
|
|
+ --bg-nav: #ffffff;
|
|
|
+ --bg-card: #ffffff;
|
|
|
+ --bg-hover: #f8fafc;
|
|
|
+ --bg-active: #eef6ff;
|
|
|
+ --border: #e2e8f0;
|
|
|
+ --border-dark: #d1d5db;
|
|
|
+ --text-primary: #1e293b;
|
|
|
+ --text-secondary: #475569;
|
|
|
+ --text-muted: #94a3b8;
|
|
|
+ --accent: #2a8bf2;
|
|
|
+ --radius: 4px;
|
|
|
+ --sidebar-w: 240px;
|
|
|
+ --nav-h: 60px;
|
|
|
+ }
|
|
|
+
|
|
|
+ body {
|
|
|
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
|
+ background: var(--bg-base);
|
|
|
+ color: var(--text-primary);
|
|
|
+ height: 100vh;
|
|
|
+ overflow: hidden;
|
|
|
+ line-height: 1.5;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* --- Global Layout --- */
|
|
|
+ .app {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100vh;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* --- Top Navigation --- */
|
|
|
+ .navbar {
|
|
|
+ height: var(--nav-h);
|
|
|
+ background: var(--bg-nav);
|
|
|
+ border-bottom: 2px solid #edeff2;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 0 40px;
|
|
|
+ z-index: 100;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .nav-logo {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #2c3e50;
|
|
|
+ margin-right: 60px;
|
|
|
+ white-space: nowrap;
|
|
|
+ letter-spacing: 0.5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .nav-menu {
|
|
|
+ display: flex;
|
|
|
+ height: 100%;
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .nav-item {
|
|
|
+ font-size: 15px;
|
|
|
+ color: var(--accent);
|
|
|
+ font-weight: 600;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 0 4px;
|
|
|
+ position: relative;
|
|
|
+ }
|
|
|
+
|
|
|
+ .nav-item::after {
|
|
|
+ content: '';
|
|
|
+ position: absolute;
|
|
|
+ bottom: -2px;
|
|
|
+ left: -4px;
|
|
|
+ right: -4px;
|
|
|
+ height: 3px;
|
|
|
+ background: var(--accent);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* --- Main Content Layout --- */
|
|
|
+ .main-container {
|
|
|
+ flex: 1;
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: var(--sidebar-w) 1fr;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* --- Sidebar --- */
|
|
|
+ .sidebar {
|
|
|
+ background: var(--bg-sidebar);
|
|
|
+ border-right: 1px solid var(--border);
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar-group {
|
|
|
+ margin-bottom: 8px;
|
|
|
+ border: 1px solid #edeff2;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar-tag {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 700;
|
|
|
+ padding: 8px 16px;
|
|
|
+ text-transform: lowercase;
|
|
|
+ width: 100%;
|
|
|
+ border-bottom: 1px solid #edeff2;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tag-how {
|
|
|
+ background: #dcfce7;
|
|
|
+ color: #166534;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tag-what {
|
|
|
+ background: #fef3c7;
|
|
|
+ color: #92400e;
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar-list {
|
|
|
+ list-style: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar-item {
|
|
|
+ padding: 10px 16px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #4b5563;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ transition: all 0.2s;
|
|
|
+ border-bottom: 1px solid #f1f5f9;
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar-item:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar-item:hover {
|
|
|
+ background: var(--bg-hover);
|
|
|
+ color: var(--text-primary);
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar-item.active {
|
|
|
+ background: #ffffff;
|
|
|
+ color: var(--accent);
|
|
|
+ font-weight: 600;
|
|
|
+ box-shadow: inset 4px 0 0 var(--accent);
|
|
|
+ }
|
|
|
+
|
|
|
+ /* --- Content Area --- */
|
|
|
+ .content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .table-container {
|
|
|
+ flex: 1;
|
|
|
+ overflow: auto;
|
|
|
+ padding: 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .records-table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ background: #ffffff;
|
|
|
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
|
+ min-width: 1000px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .records-table thead th {
|
|
|
+ background: #eff2f5;
|
|
|
+ color: #1e293b;
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ text-align: left;
|
|
|
+ padding: 12px 16px;
|
|
|
+ border: 1px solid var(--border-dark);
|
|
|
+ }
|
|
|
+
|
|
|
+ .records-table td {
|
|
|
+ padding: 12px 16px;
|
|
|
+ border: 1px solid var(--border-dark);
|
|
|
+ vertical-align: middle;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #334155;
|
|
|
+ }
|
|
|
+
|
|
|
+ .records-table tr:hover td {
|
|
|
+ background: #fcfdfe;
|
|
|
+ }
|
|
|
+
|
|
|
+ .id-col {
|
|
|
+ width: 120px;
|
|
|
+ font-family: monospace;
|
|
|
+ color: var(--text-muted);
|
|
|
+ }
|
|
|
+
|
|
|
+ .msg-col {
|
|
|
+ width: 300px;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Bubble Tree Hierarchical Styles */
|
|
|
+ .bubble-tree {
|
|
|
+ background: #ffffff;
|
|
|
+ border: 1px solid var(--border-dark);
|
|
|
+ border-radius: 4px;
|
|
|
+ padding: 4px;
|
|
|
+ min-height: 48px;
|
|
|
+ min-width: 200px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 4px 8px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.1s;
|
|
|
+ user-select: none;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-header:hover {
|
|
|
+ background: var(--bg-hover);
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-name-wrap {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-arrow {
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ color: var(--text-muted);
|
|
|
+ transition: transform 0.2s;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-arrow.open {
|
|
|
+ transform: rotate(90deg);
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-icon {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ color: #f6ad55;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-name {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text-primary);
|
|
|
+ font-weight: 500;
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-count {
|
|
|
+ font-size: 11px;
|
|
|
+ color: var(--text-muted);
|
|
|
+ background: #f1f5f9;
|
|
|
+ padding: 1px 5px;
|
|
|
+ border-radius: 3px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-children {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fg-children.open {
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
+
|
|
|
+ .file-row {
|
|
|
+ padding: 4px 8px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ transition: background 0.1s;
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .file-row:hover {
|
|
|
+ background: var(--bg-hover);
|
|
|
+ }
|
|
|
+
|
|
|
+ .f-icon {
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ color: var(--text-muted);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .f-info {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ }
|
|
|
+
|
|
|
+ .f-name-line {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .f-name {
|
|
|
+ font-size: 13px;
|
|
|
+ color: var(--text-primary);
|
|
|
+ white-space: nowrap;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ }
|
|
|
+
|
|
|
+ .f-extracted {
|
|
|
+ font-size: 11px;
|
|
|
+ color: #3b82f6;
|
|
|
+ word-break: break-all;
|
|
|
+ margin-top: 1px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .f-size {
|
|
|
+ font-size: 11px;
|
|
|
+ color: var(--text-muted);
|
|
|
+ margin-left: auto;
|
|
|
+ padding-left: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .btn-dl {
|
|
|
+ color: var(--accent);
|
|
|
+ text-decoration: none;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ opacity: 0.6;
|
|
|
+ transition: all 0.2s;
|
|
|
+ margin-left: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .btn-dl:hover {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .btn-dl svg {
|
|
|
+ width: 14px;
|
|
|
+ height: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .col-badge {
|
|
|
+ display: inline-block;
|
|
|
+ padding: 1px 6px;
|
|
|
+ border-radius: 3px;
|
|
|
+ font-size: 10px;
|
|
|
+ font-weight: 700;
|
|
|
+ margin-bottom: 4px;
|
|
|
+ text-transform: uppercase;
|
|
|
+ width: fit-content;
|
|
|
+ }
|
|
|
+
|
|
|
+ .badge-in {
|
|
|
+ background: #eff6ff;
|
|
|
+ color: #3b82f6;
|
|
|
+ border: 1px solid #dbeafe;
|
|
|
+ }
|
|
|
+
|
|
|
+ .badge-out {
|
|
|
+ background: #f0fdf4;
|
|
|
+ color: #22c55e;
|
|
|
+ border: 1px solid #dcfce7;
|
|
|
+ }
|
|
|
+
|
|
|
+ .th-cell {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: flex-start;
|
|
|
+ }
|
|
|
+
|
|
|
+ .th-label {
|
|
|
+ font-size: 13px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .state-box {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ height: 100%;
|
|
|
+ color: var(--text-muted);
|
|
|
+ }
|
|
|
+
|
|
|
+ .spinner {
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
+ border: 3px solid #e2e8f0;
|
|
|
+ border-top-color: var(--accent);
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: spin 0.8s linear infinite;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ @keyframes spin {
|
|
|
+ to {
|
|
|
+ transform: rotate(360deg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ ::-webkit-scrollbar {
|
|
|
+ width: 6px;
|
|
|
+ height: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ ::-webkit-scrollbar-track {
|
|
|
+ background: transparent;
|
|
|
+ }
|
|
|
+
|
|
|
+ ::-webkit-scrollbar-thumb {
|
|
|
+ background: #cbd5e1;
|
|
|
+ border-radius: 3px;
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+
|
|
|
+<body>
|
|
|
+ <div class="app">
|
|
|
+ <header class="navbar">
|
|
|
+ <div class="nav-logo">中后台系统</div>
|
|
|
+ <nav class="nav-menu">
|
|
|
+ <div class="nav-item">解构</div>
|
|
|
+ </nav>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div class="main-container">
|
|
|
+ <aside class="sidebar">
|
|
|
+ <div id="sidebarContent">
|
|
|
+ <div class="sidebar-group">
|
|
|
+ <div class="sidebar-tag tag-how">how</div>
|
|
|
+ <ul class="sidebar-list" id="howList"></ul>
|
|
|
+ </div>
|
|
|
+ <div class="sidebar-group">
|
|
|
+ <div class="sidebar-tag tag-what">what</div>
|
|
|
+ <ul class="sidebar-list" id="whatList"></ul>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ <main class="content">
|
|
|
+ <div class="table-container" id="contentBody">
|
|
|
+ <div class="state-box">
|
|
|
+ <div class="spinner"></div>
|
|
|
+ <p>加载中...</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <script>
|
|
|
+ const IC = {
|
|
|
+ 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>',
|
|
|
+ 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>',
|
|
|
+ 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>',
|
|
|
+ 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>',
|
|
|
+ };
|
|
|
+
|
|
|
+ const PAGE_SIZE = 20;
|
|
|
+ let S = { stages: [], stageProjectMap: {}, stage: null, records: [], skip: 0, hasMore: true, loading: false };
|
|
|
+
|
|
|
+ const $ = id => document.getElementById(id);
|
|
|
+ function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
|
+
|
|
|
+ function fmtSize(b) {
|
|
|
+ if (!b && b !== 0) return '';
|
|
|
+ const u = ['B', 'KB', 'MB', 'GB']; let i = 0, s = b;
|
|
|
+ while (s >= 1024 && i < u.length - 1) { s /= 1024; i++; }
|
|
|
+ return s.toFixed(i > 0 ? 1 : 0) + ' ' + u[i];
|
|
|
+ }
|
|
|
+
|
|
|
+ async function api(url) {
|
|
|
+ try {
|
|
|
+ const r = await fetch(url);
|
|
|
+ if (!r.ok) {
|
|
|
+ const text = await r.text();
|
|
|
+ throw new Error(`HTTP ${r.status}: ${text || r.statusText}`);
|
|
|
+ }
|
|
|
+ return r.json();
|
|
|
+ } catch (e) {
|
|
|
+ console.error('API Error:', e);
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function init() {
|
|
|
+ try {
|
|
|
+ S.stages = await api('/stages/all');
|
|
|
+ S.stages.forEach(st => { S.stageProjectMap[st.name] = st.project_id; });
|
|
|
+ renderSidebar();
|
|
|
+ const first = document.querySelector('.sidebar-item');
|
|
|
+ if (first) first.click();
|
|
|
+ } catch (e) {
|
|
|
+ $('contentBody').innerHTML = `<div class="state-box"><p style="color:red">初始化失败: ${esc(e.message)}</p></div>`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderSidebar() {
|
|
|
+ const howL = $('howList'), whatL = $('whatList');
|
|
|
+ howL.innerHTML = ''; whatL.innerHTML = '';
|
|
|
+ S.stages.forEach(st => {
|
|
|
+ const li = document.createElement('li');
|
|
|
+ li.className = 'sidebar-item';
|
|
|
+ li.innerHTML = `${esc(st.name.split('/').pop())}`;
|
|
|
+ li.onclick = () => selectStage(li, st.name);
|
|
|
+ (st.name.toLowerCase().includes('how') || st.name.toLowerCase().includes('test') ? howL : whatL).appendChild(li);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function selectStage(el, stageName) {
|
|
|
+ document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active'));
|
|
|
+ el.classList.add('active');
|
|
|
+ S.stage = stageName; S.records = []; S.skip = 0;
|
|
|
+ loadRecords();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadRecords() {
|
|
|
+ if (S.loading) return;
|
|
|
+ S.loading = true;
|
|
|
+ $('contentBody').innerHTML = '<div class="state-box"><div class="spinner"></div><p>读取流水线数据...</p></div>';
|
|
|
+ try {
|
|
|
+ const pid = S.stageProjectMap[S.stage];
|
|
|
+ if (!pid) throw new Error('项目 ID 不存在');
|
|
|
+
|
|
|
+ const url = `/projects/${pid}/records?stage=${encodeURIComponent(S.stage)}&skip=0&limit=${PAGE_SIZE}`;
|
|
|
+ const data = await api(url);
|
|
|
+ S.records = Array.isArray(data) ? data : [];
|
|
|
+ renderTable();
|
|
|
+ } catch (e) {
|
|
|
+ console.error('Load Records Error:', e);
|
|
|
+ $('contentBody').innerHTML = `<div class="state-box"><p style="color:red">加载失败: ${esc(e.message)}</p></div>`;
|
|
|
+ }
|
|
|
+ S.loading = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTable() {
|
|
|
+ if (!S.records || !S.records.length) {
|
|
|
+ $('contentBody').innerHTML = '<div class="state-box"><h2>暂无记录</h2></div>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const inLabels = new Set(), outLabels = new Set();
|
|
|
+ S.records.forEach(r => {
|
|
|
+ if (r.inputs) r.inputs.forEach(f => inLabels.add(f.label || '输入'));
|
|
|
+ if (r.outputs) r.outputs.forEach(f => outLabels.add(f.label || '输出'));
|
|
|
+ });
|
|
|
+
|
|
|
+ const sortedIn = Array.from(inLabels).sort();
|
|
|
+ const sortedOut = Array.from(outLabels).sort();
|
|
|
+
|
|
|
+ let h = `<table class="records-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th class="id-col">commit id</th>
|
|
|
+ <th class="msg-col">commit message</th>`;
|
|
|
+
|
|
|
+ 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>`);
|
|
|
+ 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>`);
|
|
|
+ h += `</tr></thead><tbody>`;
|
|
|
+
|
|
|
+ S.records.forEach(r => {
|
|
|
+ h += `<tr>
|
|
|
+ <td class="id-col">${esc(r.commit_id.substring(0, 8))}</td>
|
|
|
+ <td class="msg-col">${esc(r.commit_message || '无描述')}</td>`;
|
|
|
+
|
|
|
+ sortedIn.forEach(l => {
|
|
|
+ const files = (r.inputs || []).filter(f => (f.label || '输入') === l);
|
|
|
+ h += `<td>${renderFiles(files)}</td>`;
|
|
|
+ });
|
|
|
+ sortedOut.forEach(l => {
|
|
|
+ const files = (r.outputs || []).filter(f => (f.label || '输出') === l);
|
|
|
+ h += `<td>${renderFiles(files)}</td>`;
|
|
|
+ });
|
|
|
+ h += `</tr>`;
|
|
|
+ });
|
|
|
+
|
|
|
+ h += `</tbody></table>`;
|
|
|
+ $('contentBody').innerHTML = h;
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildFileTree(files) {
|
|
|
+ const root = { dirs: {}, files: [], path: '' };
|
|
|
+ if (!files || !files.length) return root;
|
|
|
+
|
|
|
+ files.forEach(f => {
|
|
|
+ const parts = (f.relative_path || '').split('/');
|
|
|
+ let cur = root;
|
|
|
+ for (let i = 0; i < parts.length - 1; i++) {
|
|
|
+ const p = parts[i];
|
|
|
+ if (!cur.dirs[p]) {
|
|
|
+ const curPath = cur.path ? cur.path + '/' + p : p;
|
|
|
+ cur.dirs[p] = { name: p, path: curPath, dirs: {}, files: [] };
|
|
|
+ }
|
|
|
+ cur = cur.dirs[p];
|
|
|
+ }
|
|
|
+ cur.files.push(f);
|
|
|
+ });
|
|
|
+
|
|
|
+ function compact(node) {
|
|
|
+ Object.keys(node.dirs).forEach(k => compact(node.dirs[k]));
|
|
|
+ Object.keys(node.dirs).forEach(k => {
|
|
|
+ let child = node.dirs[k];
|
|
|
+ if (!child) return;
|
|
|
+ let changed = true;
|
|
|
+ while (changed) {
|
|
|
+ changed = false;
|
|
|
+ if (Object.keys(child.dirs).length === 1 && child.files.length === 0) {
|
|
|
+ const onlyChildKey = Object.keys(child.dirs)[0];
|
|
|
+ const onlyChild = child.dirs[onlyChildKey];
|
|
|
+ child.name = child.name + '/' + onlyChild.name;
|
|
|
+ child.path = onlyChild.path;
|
|
|
+ child.dirs = onlyChild.dirs;
|
|
|
+ child.files = onlyChild.files;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ compact(root);
|
|
|
+ return root;
|
|
|
+ }
|
|
|
+
|
|
|
+ function countFiles(node) {
|
|
|
+ let cnt = node.files.length;
|
|
|
+ Object.values(node.dirs).forEach(d => { cnt += countFiles(d); });
|
|
|
+ return cnt;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTree(node, depth) {
|
|
|
+ let h = '';
|
|
|
+ const dirKeys = Object.keys(node.dirs).sort((a, b) => a.localeCompare(b));
|
|
|
+ dirKeys.forEach(k => {
|
|
|
+ const d = node.dirs[k];
|
|
|
+ const gid = 'fg_' + Math.random().toString(36).substr(2, 6);
|
|
|
+ const fileCount = countFiles(d);
|
|
|
+ const padding = `padding-left: ${depth * 16}px;`;
|
|
|
+ h += `
|
|
|
+ <div class="fg-header" style="${padding}" onclick="toggleFG('${gid}')">
|
|
|
+ <div class="fg-name-wrap">
|
|
|
+ <span class="fg-arrow" id="fa_${gid}">${IC.chevron}</span>
|
|
|
+ <span class="fg-icon">${IC.folder}</span>
|
|
|
+ <span class="fg-name" title="${esc(d.path)}">${esc(d.name)}/</span>
|
|
|
+ <span class="fg-count">${fileCount}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="fg-children" id="${gid}">
|
|
|
+ ${renderTree(d, depth + 1)}
|
|
|
+ </div>`;
|
|
|
+ });
|
|
|
+
|
|
|
+ node.files.sort((a, b) => (a.relative_path || '').localeCompare(b.relative_path || '')).forEach(f => {
|
|
|
+ const name = f.relative_path ? f.relative_path.split('/').pop() : '未知文件';
|
|
|
+ const padding = `padding-left: ${(depth === 0 ? 8 : 24 + (depth - 1) * 16)}px;`;
|
|
|
+ h += `
|
|
|
+ <div class="file-row" style="${padding}">
|
|
|
+ <span class="f-icon">${IC.file}</span>
|
|
|
+ <div class="f-info">
|
|
|
+ <div class="f-name-line">
|
|
|
+ <span class="f-name" title="${esc(f.relative_path)}">${esc(name)}</span>
|
|
|
+ <span class="f-size">${fmtSize(f.file_size)}</span>
|
|
|
+ <a class="btn-dl" href="/files/${f.id}/content" download title="下载">${IC.download}</a>
|
|
|
+ </div>
|
|
|
+ ${f.extracted_value ? `<div class="f-extracted">↳ ${esc(f.extracted_value)}</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+ });
|
|
|
+ return h;
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleFG(id) {
|
|
|
+ const ch = $(id), ar = $('fa_' + id);
|
|
|
+ if (ch) ch.classList.toggle('open');
|
|
|
+ if (ar) ar.classList.toggle('open');
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderFiles(files) {
|
|
|
+ if (!files || !files.length) return '-';
|
|
|
+ const tree = buildFileTree(files);
|
|
|
+ return `<div class="bubble-tree">${renderTree(tree, 0)}</div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ init();
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+
|
|
|
+</html>
|