| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800 |
- <!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: #f8fafc;
- --bg-sidebar: #ffffff;
- --bg-nav: #ffffff;
- --bg-card: #ffffff;
- --bg-hover: rgba(42, 139, 242, 0.05);
- --bg-active: rgba(42, 139, 242, 0.1);
- --border: #edf2f7;
- --border-dark: #e2e8f0;
- --text-primary: #1e293b;
- --text-secondary: #64748b;
- --text-muted: #94a3b8;
- --accent: #2a8bf2;
- --radius-sm: 6px;
- --radius-md: 12px;
- --radius-lg: 16px;
- --sidebar-w: 260px;
- --nav-h: 64px;
- --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
- --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
- }
- body {
- font-family: 'Outfit', 'Inter', -apple-system, sans-serif;
- background: var(--bg-base);
- color: var(--text-primary);
- height: 100vh;
- overflow: hidden;
- line-height: 1.6;
- }
- /* --- Global Layout --- */
- .app {
- display: flex;
- flex-direction: column;
- height: 100vh;
- }
- /* --- Top Navigation --- */
- .navbar {
- height: var(--nav-h);
- background: var(--bg-nav);
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- padding: 0 32px;
- z-index: 100;
- flex-shrink: 0;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
- }
- .nav-logo {
- font-size: 20px;
- font-weight: 800;
- color: #0f172a;
- margin-right: 48px;
- white-space: nowrap;
- letter-spacing: -0.5px;
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .nav-logo::before {
- content: '';
- width: 32px;
- height: 32px;
- background: linear-gradient(135deg, var(--accent), #6366f1);
- border-radius: 8px;
- }
- .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 16px;
- position: relative;
- }
- .nav-item::after {
- content: '';
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- height: 3px;
- background: var(--accent);
- border-radius: 3px 3px 0 0;
- }
- /* --- 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: 24px 12px;
- }
- .sidebar-title {
- font-size: 11px;
- font-weight: 700;
- color: var(--text-muted);
- padding: 8px 12px;
- text-transform: uppercase;
- letter-spacing: 1px;
- border-bottom: 1px solid var(--border);
- margin-bottom: 4px;
- }
- .sidebar-list {
- list-style: none;
- display: flex;
- flex-direction: column;
- gap: 4px;
- }
- .sidebar-item {
- padding: 12px 16px;
- font-size: 14px;
- color: var(--text-secondary);
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 12px;
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- border-radius: var(--radius-md);
- border: none;
- font-weight: 500;
- }
- .sidebar-item .s-icon {
- width: 20px;
- height: 20px;
- opacity: 0.7;
- }
- .sidebar-item:hover {
- background: var(--bg-hover);
- color: var(--text-primary);
- transform: translateX(4px);
- }
- .sidebar-item.active {
- background: var(--bg-active);
- color: var(--accent);
- font-weight: 600;
- box-shadow: none;
- }
- .sidebar-item.active .s-icon {
- opacity: 1;
- color: var(--accent);
- }
- /* --- Content Area --- */
- .content {
- display: flex;
- flex-direction: column;
- overflow: hidden;
- padding: 24px 32px;
- }
- .table-container {
- flex: 1;
- overflow: auto;
- background: #ffffff;
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow);
- border: 1px solid var(--border);
- }
- .records-table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0;
- min-width: 1000px;
- }
- .records-table thead th {
- background: #f8fafc;
- color: var(--text-secondary);
- font-size: 12px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- text-align: left;
- padding: 16px 20px;
- border-bottom: 1px solid var(--border);
- position: sticky;
- top: 0;
- z-index: 10;
- }
- .records-table td {
- padding: 20px;
- border-bottom: 1px solid var(--border);
- vertical-align: top;
- font-size: 14px;
- color: var(--text-primary);
- transition: background 0.15s;
- }
- .records-table tr:last-child td {
- border-bottom: none;
- }
- .records-table tr:hover td {
- background: #fcfdfe;
- }
- .id-col {
- width: 100px;
- }
- .commit-badge {
- font-family: 'JetBrains Mono', monospace;
- background: #f1f5f9;
- color: var(--text-secondary);
- padding: 4px 8px;
- border-radius: var(--radius-sm);
- font-size: 12px;
- display: inline-block;
- }
- .msg-col {
- width: 260px;
- font-weight: 600;
- color: #0f172a;
- }
- /* Bubble Tree Hierarchical Styles */
- .bubble-tree {
- background: #ffffff;
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- padding: 8px;
- min-height: 48px;
- min-width: 220px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
- }
- .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">
- <ul class="sidebar-list" id="stageList"></ul>
- </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>',
- // Stage icons
- 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>',
- 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>',
- 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>',
- 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>',
- };
- 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 list = $('stageList');
- list.innerHTML = '';
- const STAGE_ORDER = ['how选题', 'what选题', 'what创作', 'what制作'];
- const STAGE_ICONS = {
- 'how选题': IC.topic,
- 'what选题': IC.what,
- 'what创作': IC.craft,
- 'what制作': IC.gear
- };
- const sortedStages = [...S.stages].sort((a, b) => {
- const idxA = STAGE_ORDER.indexOf(a.name);
- const idxB = STAGE_ORDER.indexOf(b.name);
- if (idxA !== -1 && idxB !== -1) return idxA - idxB;
- if (idxA !== -1) return -1;
- if (idxB !== -1) return 1;
- return a.name.localeCompare(b.name);
- });
- sortedStages.forEach(st => {
- const li = document.createElement('li');
- li.className = 'sidebar-item';
- const icon = STAGE_ICONS[st.name] || IC.file;
- li.innerHTML = `<span class="s-icon">${icon}</span>${esc(st.name)}`;
- li.onclick = () => selectStage(li, st.name);
- list.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"><span class="commit-badge">${esc(r.commit_id.substring(0, 8))}</span></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>${renderPlainFiles(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 renderFileItem(f, depth = 0) {
- const name = f.relative_path ? f.relative_path.split('/').pop() : '未知文件';
- const padding = `padding-left: ${(depth === 0 ? 8 : 24 + (depth - 1) * 16)}px;`;
- return `
- <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>`;
- }
- 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 => {
- h += renderFileItem(f, depth);
- });
- 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 '-';
- // 如果只有一个文件,不再使用文件夹树,直接展示文件
- if (files.length === 1) {
- return renderPlainFiles(files);
- }
- const tree = buildFileTree(files);
- return `<div class="bubble-tree">${renderTree(tree, 0)}</div>`;
- }
- function renderPlainFiles(files) {
- if (!files || !files.length) return '-';
- // Flat list for outputs, slightly cleaner padding
- return `<div class="bubble-tree" style="min-width: 180px;">${files.map(f => renderFileItem(f, 0)).join('')}</div>`;
- }
- init();
- </script>
- </body>
- </html>
|