| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Data Nexus - 数据中台</title>
- <meta name="description" content="Data Nexus 轻量级数据中台,浏览和下载项目数据文件">
- <style>
- *,
- *::before,
- *::after {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
- :root {
- --bg-primary: #0f172a;
- --bg-surface: #1e293b;
- --bg-hover: #334155;
- --bg-card: rgba(30, 41, 59, 0.7);
- --border: rgba(148, 163, 184, 0.1);
- --border-hover: rgba(59, 130, 246, 0.4);
- --text-primary: #f1f5f9;
- --text-secondary: #94a3b8;
- --text-muted: #64748b;
- --accent: #3b82f6;
- --accent-light: #60a5fa;
- --accent-glow: rgba(59, 130, 246, 0.15);
- --green: #22c55e;
- --orange: #f59e0b;
- --purple: #a78bfa;
- --radius: 10px;
- }
- body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
- background: var(--bg-primary);
- color: var(--text-primary);
- min-height: 100vh;
- line-height: 1.6;
- }
- /* Header */
- .header {
- background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.9));
- border-bottom: 1px solid var(--border);
- backdrop-filter: blur(20px);
- position: sticky;
- top: 0;
- z-index: 100;
- }
- .header-inner {
- max-width: 1200px;
- margin: 0 auto;
- padding: 16px 24px;
- }
- .header-top {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 12px;
- }
- .logo-icon {
- width: 32px;
- height: 32px;
- color: var(--accent);
- }
- .header-top h1 {
- font-size: 20px;
- font-weight: 700;
- background: linear-gradient(135deg, var(--accent-light), var(--purple));
- -webkit-background-clip: text;
- background-clip: text;
- -webkit-text-fill-color: transparent;
- }
- /* Breadcrumb */
- .breadcrumb {
- display: flex;
- align-items: center;
- gap: 4px;
- flex-wrap: wrap;
- font-size: 14px;
- }
- .breadcrumb-item {
- color: var(--text-muted);
- cursor: pointer;
- padding: 2px 8px;
- border-radius: 4px;
- transition: all 0.2s;
- }
- .breadcrumb-item:hover {
- color: var(--accent-light);
- background: var(--accent-glow);
- }
- .breadcrumb-item.active {
- color: var(--text-primary);
- cursor: default;
- }
- .breadcrumb-item.active:hover {
- background: none;
- color: var(--text-primary);
- }
- .breadcrumb-sep {
- color: var(--text-muted);
- font-size: 12px;
- }
- .breadcrumb-sep svg {
- width: 14px;
- height: 14px;
- vertical-align: middle;
- }
- /* Main content */
- .main {
- max-width: 1200px;
- margin: 0 auto;
- padding: 24px;
- }
- /* Item list */
- .item-list {
- background: var(--bg-card);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- overflow: hidden;
- backdrop-filter: blur(10px);
- }
- .item {
- display: flex;
- align-items: center;
- gap: 14px;
- padding: 14px 20px;
- border-bottom: 1px solid var(--border);
- cursor: pointer;
- transition: all 0.2s ease;
- animation: fadeSlideIn 0.3s ease forwards;
- opacity: 0;
- }
- .item:last-child {
- border-bottom: none;
- }
- .item:hover {
- background: var(--bg-hover);
- border-color: var(--border-hover);
- }
- .item-icon {
- flex-shrink: 0;
- width: 20px;
- height: 20px;
- }
- .item-icon svg {
- width: 20px;
- height: 20px;
- }
- .item-icon.project {
- color: var(--accent-light);
- }
- .item-icon.stage {
- color: var(--orange);
- }
- .item-icon.commit {
- color: var(--green);
- }
- .item-icon.file {
- color: var(--text-muted);
- }
- .item-icon.folder {
- color: var(--orange);
- }
- .item-body {
- flex: 1;
- min-width: 0;
- }
- .item-name {
- font-size: 15px;
- font-weight: 500;
- color: var(--text-primary);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .item-desc {
- font-size: 12px;
- color: var(--text-muted);
- margin-top: 2px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .item-meta {
- flex-shrink: 0;
- text-align: right;
- font-size: 12px;
- color: var(--text-muted);
- }
- .item-meta .meta-main {
- color: var(--text-secondary);
- font-size: 13px;
- }
- .item-arrow {
- flex-shrink: 0;
- color: var(--text-muted);
- transition: transform 0.2s;
- }
- .item:hover .item-arrow {
- color: var(--accent-light);
- transform: translateX(3px);
- }
- .item-arrow svg {
- width: 16px;
- height: 16px;
- }
- /* Download button */
- .btn-download {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 6px 14px;
- border-radius: 6px;
- border: none;
- background: var(--accent);
- color: white;
- font-size: 12px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s;
- text-decoration: none;
- }
- .btn-download:hover {
- background: var(--accent-light);
- transform: translateY(-1px);
- }
- .btn-download svg {
- width: 14px;
- height: 14px;
- }
- /* File tree */
- .file-tree {
- padding: 8px 0;
- }
- .tree-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 20px;
- border-bottom: 1px solid var(--border);
- transition: background 0.15s;
- animation: fadeSlideIn 0.3s ease forwards;
- opacity: 0;
- }
- .tree-item:last-child {
- border-bottom: none;
- }
- .tree-item:hover {
- background: var(--bg-hover);
- }
- .tree-item .indent {
- flex-shrink: 0;
- }
- .tree-item .item-icon {
- flex-shrink: 0;
- }
- .tree-item .item-body {
- flex: 1;
- min-width: 0;
- }
- .tree-item .item-name {
- font-size: 14px;
- }
- .tree-folder-toggle {
- cursor: pointer;
- color: var(--text-muted);
- width: 16px;
- height: 16px;
- flex-shrink: 0;
- transition: transform 0.2s;
- }
- .tree-folder-toggle.expanded {
- transform: rotate(90deg);
- }
- .tree-children {
- display: none;
- }
- .tree-children.expanded {
- display: block;
- }
- /* Status badges */
- .badge {
- display: inline-block;
- padding: 2px 8px;
- border-radius: 4px;
- font-size: 11px;
- font-weight: 600;
- }
- .badge-stage {
- background: rgba(245, 158, 11, 0.15);
- color: var(--orange);
- border: 1px solid rgba(245, 158, 11, 0.25);
- }
- .badge-count {
- background: rgba(59, 130, 246, 0.15);
- color: var(--accent-light);
- border: 1px solid rgba(59, 130, 246, 0.2);
- }
- /* Loading & Empty states */
- .state-container {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 80px 20px;
- color: var(--text-muted);
- }
- .state-container svg {
- width: 48px;
- height: 48px;
- margin-bottom: 16px;
- opacity: 0.5;
- }
- .state-container p {
- font-size: 15px;
- }
- .spinner {
- width: 32px;
- height: 32px;
- border: 3px solid var(--border);
- border-top-color: var(--accent);
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
- margin-bottom: 16px;
- }
- /* Section header */
- .section-header {
- padding: 12px 20px;
- background: rgba(15, 23, 42, 0.5);
- border-bottom: 1px solid var(--border);
- font-size: 12px;
- color: var(--text-muted);
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- }
- /* Animations */
- @keyframes fadeSlideIn {
- from {
- opacity: 0;
- transform: translateY(8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
- @keyframes spin {
- to {
- transform: rotate(360deg);
- }
- }
- /* Responsive */
- @media (max-width: 640px) {
- .header-inner,
- .main {
- padding-left: 16px;
- padding-right: 16px;
- }
- .item {
- padding: 12px 14px;
- gap: 10px;
- }
- .item-meta {
- display: none;
- }
- }
- </style>
- </head>
- <body>
- <header class="header">
- <div class="header-inner">
- <div class="header-top">
- <svg class="logo-icon" 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>
- <h1>Data Nexus</h1>
- </div>
- <nav id="breadcrumb" class="breadcrumb"></nav>
- </div>
- </header>
- <main class="main">
- <div id="content"></div>
- </main>
- <script>
- // ============ SVG Icons ============
- const SVG = {
- 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>',
- 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>',
- 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>',
- 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>',
- 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>',
- chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>',
- 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>',
- 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>',
- };
- const chevronSep = `<span class="breadcrumb-sep">${SVG.chevron}</span>`;
- // ============ State ============
- let state = {
- project: null,
- stage: null,
- version: null,
- versionsCache: [], // all versions for current project
- };
- const $content = document.getElementById('content');
- const $breadcrumb = document.getElementById('breadcrumb');
- // ============ Utils ============
- function formatSize(bytes) {
- if (!bytes && bytes !== 0) return '-';
- const u = ['B', 'KB', 'MB', 'GB', 'TB'];
- let i = 0, s = bytes;
- while (s >= 1024 && i < u.length - 1) { s /= 1024; i++; }
- return s.toFixed(i > 0 ? 1 : 0) + ' ' + u[i];
- }
- function formatTime(iso) {
- if (!iso) return '';
- const d = new Date(iso);
- const pad = n => String(n).padStart(2, '0');
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
- }
- function relativeTime(iso) {
- if (!iso) return '';
- const diff = Date.now() - new Date(iso).getTime();
- const m = Math.floor(diff / 60000);
- if (m < 1) return '刚刚';
- if (m < 60) return m + ' 分钟前';
- const h = Math.floor(m / 60);
- if (h < 24) return h + ' 小时前';
- const d = Math.floor(h / 24);
- if (d < 30) return d + ' 天前';
- return formatTime(iso);
- }
- function staggerDelay(i) {
- return `animation-delay: ${i * 0.04}s;`;
- }
- async function api(path) {
- const res = await fetch(path);
- if (!res.ok) throw new Error(`API Error: ${res.status}`);
- return res.json();
- }
- // ============ Rendering ============
- function renderLoading() {
- $content.innerHTML = `<div class="item-list"><div class="state-container"><div class="spinner"></div><p>加载中...</p></div></div>`;
- }
- function renderEmpty(msg) {
- $content.innerHTML = `<div class="item-list"><div class="state-container">${SVG.empty}<p>${msg}</p></div></div>`;
- }
- function renderError(msg) {
- $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>`;
- }
- // ============ Breadcrumb ============
- function updateBreadcrumb() {
- let html = `<span class="breadcrumb-item ${!state.project ? 'active' : ''}" onclick="loadProjects()">所有项目</span>`;
- if (state.project) {
- html += chevronSep + `<span class="breadcrumb-item ${!state.stage ? 'active' : ''}" onclick="selectProject('${state.project.id}')">${state.project.project_name}</span>`;
- }
- if (state.stage) {
- html += chevronSep + `<span class="breadcrumb-item ${!state.version ? 'active' : ''}" onclick="selectStage('${state.stage}')">${state.stage}</span>`;
- }
- if (state.version) {
- html += chevronSep + `<span class="breadcrumb-item active">${state.version.commit_id.substring(0, 8)}</span>`;
- }
- $breadcrumb.innerHTML = html;
- }
- // ============ Level 1: Projects ============
- async function loadProjects() {
- state = { project: null, stage: null, version: null, versionsCache: [] };
- updateBreadcrumb();
- renderLoading();
- try {
- const projects = await api('/projects?limit=500');
- if (!projects.length) return renderEmpty('暂无项目');
- let html = '<div class="item-list"><div class="section-header">项目列表</div>';
- projects.forEach((p, i) => {
- html += `<div class="item" style="${staggerDelay(i)}" onclick="selectProject('${p.id}')">
- <div class="item-icon project">${SVG.project}</div>
- <div class="item-body">
- <div class="item-name">${esc(p.project_name)}</div>
- ${p.description ? `<div class="item-desc">${esc(p.description)}</div>` : ''}
- </div>
- <div class="item-meta"><div class="meta-main">${relativeTime(p.created_at)}</div><div>${formatTime(p.created_at)}</div></div>
- <div class="item-arrow">${SVG.chevron}</div>
- </div>`;
- });
- html += '</div>';
- $content.innerHTML = html;
- } catch (e) { renderError('加载项目失败: ' + e.message); }
- }
- // ============ Level 2: Stages ============
- async function selectProject(projectId) {
- renderLoading();
- try {
- const projects = await api('/projects?limit=500');
- state.project = projects.find(p => p.id === projectId);
- state.stage = null; state.version = null;
- const versions = await api(`/projects/${projectId}/versions?limit=5000`);
- state.versionsCache = versions;
- updateBreadcrumb();
- // Extract unique stages with stats
- const stageMap = {};
- versions.forEach(v => {
- if (!stageMap[v.stage]) stageMap[v.stage] = { count: 0, latest: v.created_at };
- stageMap[v.stage].count++;
- if (new Date(v.created_at) > new Date(stageMap[v.stage].latest))
- stageMap[v.stage].latest = v.created_at;
- });
- const stages = Object.entries(stageMap).sort((a, b) => a[0].localeCompare(b[0]));
- if (!stages.length) return renderEmpty('该项目暂无数据');
- let html = '<div class="item-list"><div class="section-header">数据阶段</div>';
- stages.forEach(([name, info], i) => {
- html += `<div class="item" style="${staggerDelay(i)}" onclick="selectStage('${esc(name)}')">
- <div class="item-icon stage">${SVG.folder}</div>
- <div class="item-body">
- <div class="item-name">${esc(name)}</div>
- <div class="item-desc"><span class="badge badge-count">${info.count} 次提交</span></div>
- </div>
- <div class="item-meta"><div class="meta-main">最近更新</div><div>${relativeTime(info.latest)}</div></div>
- <div class="item-arrow">${SVG.chevron}</div>
- </div>`;
- });
- html += '</div>';
- $content.innerHTML = html;
- } catch (e) { renderError('加载阶段失败: ' + e.message); }
- }
- // ============ Level 3: Commits/Versions ============
- function selectStage(stageName) {
- state.stage = stageName; state.version = null;
- updateBreadcrumb();
- const versions = state.versionsCache
- .filter(v => v.stage === stageName)
- .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
- if (!versions.length) return renderEmpty('该阶段暂无提交');
- let html = '<div class="item-list"><div class="section-header">提交记录</div>';
- versions.forEach((v, i) => {
- const shortId = v.commit_id.substring(0, 8);
- html += `<div class="item" style="${staggerDelay(i)}" onclick="selectVersion('${v.id}')">
- <div class="item-icon commit">${SVG.commit}</div>
- <div class="item-body">
- <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>
- <div class="item-desc">${v.author ? '作者: ' + esc(v.author) : ''}</div>
- </div>
- <div class="item-meta"><div class="meta-main">${relativeTime(v.created_at)}</div><div>${formatTime(v.created_at)}</div></div>
- <div class="item-arrow">${SVG.chevron}</div>
- </div>`;
- });
- html += '</div>';
- $content.innerHTML = html;
- }
- // ============ Level 4: Files ============
- async function selectVersion(versionId) {
- renderLoading();
- try {
- const v = state.versionsCache.find(x => x.id === versionId);
- if (v) state.version = v;
- updateBreadcrumb();
- const tree = await api(`/versions/${versionId}/files`);
- if (!tree.length) return renderEmpty('该提交暂无文件');
- let html = '<div class="item-list"><div class="section-header">文件列表</div><div class="file-tree">';
- html += renderTree(tree, 0);
- html += '</div></div>';
- $content.innerHTML = html;
- } catch (e) { renderError('加载文件失败: ' + e.message); }
- }
- function renderTree(nodes, depth) {
- let html = '';
- let idx = 0;
- nodes.forEach(node => {
- const indent = `<span class="indent" style="width:${depth * 24}px;display:inline-block;"></span>`;
- if (node.type === 'folder') {
- const folderId = 'f_' + Math.random().toString(36).substr(2, 8);
- html += `<div class="tree-item" style="${staggerDelay(idx++)}" onclick="toggleFolder('${folderId}', this)">
- ${indent}
- <span class="tree-folder-toggle" id="toggle_${folderId}">${SVG.chevron}</span>
- <div class="item-icon folder">${SVG.folder}</div>
- <div class="item-body"><div class="item-name">${esc(node.name)}</div></div>
- </div>
- <div class="tree-children" id="${folderId}">
- ${renderTree(node.children || [], depth + 1)}
- </div>`;
- } else {
- html += `<div class="tree-item" style="${staggerDelay(idx++)}">
- ${indent}
- <span style="width:16px;display:inline-block;"></span>
- <div class="item-icon file">${SVG.file}</div>
- <div class="item-body">
- <div class="item-name">${esc(node.name)}</div>
- <div class="item-desc">${formatSize(node.size)}${node.file_type ? ' · ' + node.file_type : ''}</div>
- </div>
- <a class="btn-download" href="/files/${node.id}/content" download="${esc(node.name)}" onclick="event.stopPropagation();">
- ${SVG.download} 下载
- </a>
- </div>`;
- }
- });
- return html;
- }
- function toggleFolder(id, el) {
- const children = document.getElementById(id);
- const toggle = document.getElementById('toggle_' + id);
- if (children) children.classList.toggle('expanded');
- if (toggle) toggle.classList.toggle('expanded');
- }
- function esc(s) {
- if (!s) return '';
- const d = document.createElement('div');
- d.textContent = s;
- return d.innerHTML;
- }
- // ============ Init ============
- loadProjects();
- </script>
- </body>
- </html>
|