testFlow/ui/page/doc_list.html
Wyle.Gong-巩文昕 7a1aae1e2f ui
2025-04-23 11:21:08 +08:00

1345 lines
49 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>文档管理 - 卡片视图</title>
<!-- Include D3 library for the preview -->
<script src="/assets/d3.js"></script>
<style>
/* === Reset & Base === */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f0f2f5;
/* Softer background */
font-size: 14px;
/* Base font size */
color: #333;
line-height: 1.6;
}
/* === Container & Header === */
body .container {
max-width: 1300px;
margin: auto;
background-color: #fff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
body .breadcrumb {
color: #555;
font-size: 18px;
font-weight: 500;
}
body .breadcrumb a {
color: #1890ff;
text-decoration: none;
transition: color 0.3s;
}
body .breadcrumb a:hover {
color: #40a9ff;
}
body .header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 20px;
margin-bottom: 25px;
border-bottom: 1px solid #e8e8e8;
}
body .header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
/* === Control Panel & Search === */
body .control-panel {
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e8e8e8;
padding-bottom: 20px;
margin-bottom: 30px;
display: flex;
gap: 15px;
align-items: center;
}
body .search-box {
flex-grow: 1;
}
body input[type="text"].search-input {
/* Specific class for search */
width: 100%;
padding: 10px 15px;
/* Slightly larger padding */
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s, box-shadow 0.3s;
}
body input[type="text"].search-input:focus {
border-color: #40a9ff;
outline: none;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
/* === Buttons (General - Copied from previous example) === */
body button,
.btn {
/* Apply btn class styling to buttons */
padding: 8px 18px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
margin: 0;
/* Reset margin */
}
body body button:hover,
.btn:hover {
background-color: #40a9ff;
box-shadow: 0 2px 5px rgba(24, 144, 255, 0.3);
}
body button:focus,
.btn:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
body button.danger,
.btn-danger {
background-color: #ff4d4f;
}
body button.danger:hover,
.btn-danger:hover {
background-color: #ff7875;
box-shadow: 0 2px 5px rgba(255, 77, 79, 0.3);
}
body button.danger:focus,
.btn-danger:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2);
}
body button.secondary,
.btn-secondary {
background-color: #fff;
color: #555;
border: 1px solid #d9d9d9;
}
body button.secondary:hover,
.btn-secondary:hover {
color: #1890ff;
border-color: #1890ff;
box-shadow: none;
}
body button.secondary:focus,
.btn-secondary:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
border-color: #40a9ff;
}
/* === Card Layout === */
body #docs-card-container {
/* Changed from docs-table-container */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
/* Responsive grid */
gap: 25px;
/* Space between cards */
margin-top: 20px;
}
body .doc-card {
background-color: #fff;
border: 1px solid #e8e8e8;
border-radius: 6px;
/* padding: 20px; */
/* Remove padding, handle internally */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: box-shadow 0.3s ease, transform 0.3s ease;
display: flex;
flex-direction: column;
/* Stack content vertically */
overflow: hidden;
/* Important for contained preview */
}
body .doc-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-3px);
}
body .card-content {
/* Existing container for top content */
padding: 20px;
flex-grow: 1;
}
/* Progress Bar Section Styles */
body .card-progress-section {
margin-top: 10px;
/* Space above progress bar */
margin-bottom: 15px;
/* Space below progress bar */
}
body .progress-label {
display: block;
/* Label on its own line */
font-size: 11px;
/* Smaller font for status */
color: #555;
margin-bottom: 5px;
font-weight: 500;
}
/* Status specific label colors */
body .progress-label.status-success {
color: #52c41a;
}
body .progress-label.status-error {
color: #ff4d4f;
}
body .progress-label.status-in-progress {
color: #1890ff;
}
body .progress-label.status-warning {
color: #faad14;
}
body .progress-bar-container {
height: 8px;
/* Thin progress bar */
background-color: #e9e9e9;
/* Track color */
border-radius: 4px;
overflow: hidden;
width: 100%;
}
body .progress-bar-fill {
height: 100%;
background-color: #1890ff;
/* Default/In-progress color */
border-radius: 4px;
transition: width 0.4s ease-in-out;
/* Smooth transition */
text-align: right;
/* For potential inner text, though not used here */
/* font-size: 10px; */
/* line-height: 8px; */
/* color: white; */
/* padding-right: 5px; */
}
/* Status specific fill colors */
body .progress-bar-fill.success {
background-color: #52c41a;
}
/* Green */
body .progress-bar-fill.error {
background-color: #ff4d4f;
}
/* Red */
body .progress-bar-fill.warning {
background-color: #faad14;
}
/* Orange */
body .card-header {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
word-break: break-all;
}
body .card-meta {
font-size: 12px;
color: #999;
margin-bottom: 15px;
}
body .card-meta span {
margin-right: 15px;
}
body .card-body {
font-size: 14px;
color: #666;
margin-bottom: 20px;
}
body .card-body p {
margin: 0 0 8px 0;
}
body .card-body strong {
color: #444;
}
/* Tree Preview Area */
body .card-tree-preview-container {
/* border-top: 1px solid #f0f0f0; */
/* Optional separator */
/* margin-top: 15px; */
/* padding-top: 15px; */
position: relative;
min-height: 120px;
/* Fixed height */
height: 120px;
background-color: #fafafa;
/* Slight background tint */
/* Removed margins/padding that extended outside card */
overflow: hidden;
border-top: 1px solid #f0f0f0;
}
body .card-tree-preview {
width: 100%;
height: 100%;
overflow: hidden;
}
body .card-tree-preview svg {
display: block;
width: 100%;
height: 100%;
}
/* Styles for elements inside the tree preview SVG */
body .card-tree-preview .node rect {
fill: #e6f7ff;
/* Light blue */
stroke: #91d5ff;
/* Medium blue */
stroke-width: 1px;
rx: 2;
ry: 2;
/* Smaller radius */
}
body .card-tree-preview .node text {
display: none;
/* Hide text in preview */
}
body .card-tree-preview .link {
fill: none;
stroke: #bae7ff;
/* Lighter blue */
stroke-width: 1px;
}
/* Loading indicator for preview */
body .card-tree-preview-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(250, 250, 250, 0.8);
z-index: 1;
font-size: 12px;
color: #888;
}
body .mini-spinner {
border: 2px solid rgba(0, 0, 0, 0.1);
border-left-color: #1890ff;
border-radius: 50%;
width: 16px;
height: 16px;
animation: spin 0.8s linear infinite;
margin-right: 8px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Card Actions */
body .card-actions {
padding: 15px 20px;
border-top: 1px solid #f0f0f0;
background-color: #fff;
/* Ensure background */
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-start;
}
body .card-actions button,
.card-actions .btn {
padding: 6px 12px;
font-size: 13px;
}
/* === Modal Styles (Adapted from previous example) === */
body .modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 20px;
}
body .modal-content {
background-color: white;
padding: 30px;
border-radius: 6px;
width: 500px;
max-width: 95%;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
animation: modal-fade-in 0.3s ease-out;
}
@keyframes modal-fade-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
body .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
body .modal-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0;
}
body .close {
font-size: 28px;
cursor: pointer;
color: #999;
transition: color 0.3s;
line-height: 1;
background: none;
border: none;
padding: 0;
}
body .close:hover {
color: #555;
}
body .form-group {
margin-bottom: 20px;
}
body .form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #444;
font-size: 14px;
}
body .form-input,
select.form-input,
textarea.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-sizing: border-box;
font-size: 14px;
transition: border-color 0.3s, box-shadow 0.3s;
}
body .form-input:focus,
select.form-input:focus,
textarea.form-input:focus {
border-color: #40a9ff;
outline: none;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
body textarea.form-input {
min-height: 100px;
resize: vertical;
}
body select.form-input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23666' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px 12px;
padding-right: 35px;
}
body .modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 30px;
}
/* File Upload */
body .file-upload {
margin-top: 10px;
}
body .file-upload input[type="file"] {
display: none;
}
body .file-upload label.btn-secondary {
/* Style the label like a button */
display: inline-block;
/* Needed for button styling */
/* Inherit button styles */
}
body .file-name {
margin-left: 10px;
font-size: 14px;
color: #666;
vertical-align: middle;
}
/* === Empty State === */
body .empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
background-color: #fafafa;
border-radius: 6px;
border: 1px dashed #d9d9d9;
margin-top: 20px;
}
body .empty-state p {
font-size: 16px;
margin: 0 0 20px 0;
}
/* Hide original table */
body #docs-table {
display: none;
}
</style>
</head>
<body>
<div class="container">
<!-- <div class="header">
<h1>文档管理</h1>
<div>
<button id="back-btn" class="btn btn-secondary" style="display: none;">返回项目列表</button>
</div>
</div> -->
<div class="control-panel">
<div class="breadcrumb">
<a href="project_list.html">项目列表</a> / 文档管理-
<span id="project-name"></span>
</div>
<!-- <div class="search-box">
<input type="text" id="search-input" class="search-input" placeholder="搜索文档名称或描述..." />
</div> -->
<button id="add-doc-btn" class="btn">添加文档</button>
<button id="refresh-btn" class="btn btn-secondary" style="display: none;">刷新</button>
</div>
<!-- Container for cards, replaces table container -->
<div id="docs-card-container">
<!-- Document cards will be generated here by JavaScript -->
</div>
<!-- Empty state remains -->
<div id="empty-state" class="empty-state" style="display: none">
<p>暂无文档数据</p>
<button id="create-first-doc-btn" class="btn">创建第一个文档</button>
</div>
</div>
<!-- Add/Edit Document Modal -->
<div id="doc-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title" class="modal-title">添加文档</h2>
<button class="close" aria-label="Close">×</button>
</div>
<form id="doc-form">
<input type="hidden" id="doc-id" />
<div class="form-group">
<label for="doc-name" class="form-label">文档名称</label>
<input type="text" id="doc-name" class="form-input" required />
</div>
<div class="form-group">
<label for="doc-type" class="form-label">文档类型</label>
<select id="doc-type" class="form-input">
<option value="requirement">需求文档</option>
<option value="api">API文档</option>
<option value="design">设计文档</option>
<option value="other">其他</option>
</select>
</div>
<div class="form-group">
<label for="doc-description" class="form-label">文档描述</label>
<textarea id="doc-description" class="form-input"></textarea>
</div>
<div class="form-group file-upload">
<label for="doc-file" class="btn btn-secondary">选择文件</label>
<input type="file" id="doc-file" />
<span class="file-name" id="file-name">未选择文件</span>
</div>
<div class="modal-footer">
<button type="button" id="cancel-btn" class="btn btn-secondary">取消</button>
<button type="button" id="save-doc-btn" class="btn">保存</button>
<!-- Changed to type=button to prevent default form submission -->
</div>
</form>
</div>
</div>
<!-- Confirm Delete Modal -->
<div id="confirm-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">确认删除</h2>
<button class="close" aria-label="Close">×</button>
</div>
<p style="margin: 20px 0; color: #555;">确定要删除这个文档吗?此操作不可撤销。</p>
<div class="modal-footer">
<button type="button" id="cancel-delete-btn" class="btn btn-secondary">取消</button>
<button type="button" id="confirm-delete-btn" class="btn btn-danger">删除</button>
</div>
</div>
</div>
<script>
// 全局变量
let docs = [];
let currentDocIdToDelete = null; // Renamed for clarity
let projectId = null;
let projectName = "";
// DOM元素
const docCardContainer = document.getElementById("docs-card-container"); // Changed
const emptyStateEl = document.getElementById("empty-state");
const searchInputEl = document.getElementById("search-input");
const docModal = document.getElementById("doc-modal");
const confirmModal = document.getElementById("confirm-modal");
const modalTitle = document.getElementById("modal-title");
const docForm = document.getElementById("doc-form");
const docIdInput = document.getElementById("doc-id");
const docNameInput = document.getElementById("doc-name");
const docTypeInput = document.getElementById("doc-type");
const docDescInput = document.getElementById("doc-description");
const docFileInput = document.getElementById("doc-file");
const fileNameEl = document.getElementById("file-name");
const projectNameEl = document.getElementById("project-name");
// Buttons
//const backBtn = document.getElementById("back-btn");
const addDocBtn = document.getElementById("add-doc-btn");
const refreshBtn = document.getElementById("refresh-btn");
const createFirstDocBtn = document.getElementById("create-first-doc-btn");
const saveDocBtn = document.getElementById("save-doc-btn");
const cancelBtn = document.getElementById("cancel-btn");
const confirmDeleteBtn = document.getElementById("confirm-delete-btn");
const cancelDeleteBtn = document.getElementById("cancel-delete-btn");
const closeButtons = document.querySelectorAll(".close");
// API Base URL
const API_BASE_URL = "http://127.0.0.1:5002/api"; // Define API base URL
// === Initialization ===
init();
function init() {
const urlParams = new URLSearchParams(window.location.search);
projectId = urlParams.get("project_id");
if (!projectId) {
alert("未指定项目ID将返回项目列表");
window.location.href = "project_list.html";
return;
}
fetchProjectInfo();
fetchDocs();
setupEventListeners();
}
// === Event Listeners Setup ===
function setupEventListeners() {
// backBtn.addEventListener("click", () => { window.location.href = "project_list.html"; });
addDocBtn.addEventListener("click", () => openDocModal());
refreshBtn.addEventListener("click", fetchDocs);
createFirstDocBtn.addEventListener("click", () => openDocModal());
saveDocBtn.addEventListener("click", saveDoc); // Changed from form submit
cancelBtn.addEventListener("click", closeDocModal);
confirmDeleteBtn.addEventListener("click", deleteDoc);
cancelDeleteBtn.addEventListener("click", closeConfirmModal);
closeButtons.forEach((button) => {
button.addEventListener("click", function () {
const modal = this.closest('.modal');
if (modal) modal.style.display = "none";
if (modal === docModal) { closeDocModal(); } // Reset form on close
if (modal === confirmModal) { closeConfirmModal(); }
});
});
// searchInputEl.addEventListener("input", filterDocs);
docFileInput.addEventListener("change", function () {
if (this.files.length > 0) {
fileNameEl.textContent = this.files[0].name;
// Auto-fill name only if it's empty
if (docNameInput.value.trim() === "") {
// Remove extension for cleaner name
const nameWithoutExt = this.files[0].name.replace(/\.[^/.]+$/, "");
docNameInput.value = nameWithoutExt;
}
} else {
fileNameEl.textContent = "未选择文件";
}
});
// Close modal on outside click
window.addEventListener("click", function (event) {
if (event.target === docModal) closeDocModal();
if (event.target === confirmModal) closeConfirmModal();
});
// Event delegation for card actions
docCardContainer.addEventListener('click', handleCardActionClick);
}
// === Data Fetching ===
async function fetchProjectInfo() {
try {
const response = await fetch(`${API_BASE_URL}/project/${projectId}`);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const result = await response.json();
if (result.code === 0 && result.data) {
projectName = result.data.name || "未命名项目";
projectNameEl.textContent = projectName;
document.title = `${projectName} - 文档管理`;
} else { console.error("获取项目信息失败:", result.message || "未知错误"); }
} catch (error) { console.error("获取项目信息错误:", error); }
}
async function fetchDocs() {
// Show loading indicator if you have one
try {
const response = await fetch(`${API_BASE_URL}/doc/${projectId}/`);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const result = await response.json();
if (result.code === 0 && Array.isArray(result.data)) {
docs = result.data.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
renderDocCards(docs); // Call new render function
} else {
console.error("获取文档失败:", result.message || "未知错误");
showEmptyState(true, "加载失败,请重试");
renderDocCards([]); // Ensure empty state is handled
}
} catch (error) {
console.error("获取文档错误:", error);
showEmptyState(true, "加载失败,请重试");
renderDocCards([]); // Ensure empty state is handled
} finally {
// Hide loading indicator
}
}
// === Rendering ===
function renderDocCards(docsToRender) {
if (!docsToRender || docsToRender.length === 0) {
showEmptyState(true);
docCardContainer.style.display = "none";
return;
}
showEmptyState(false);
docCardContainer.innerHTML = "";
docCardContainer.style.display = "grid";
docsToRender.forEach(doc => {
const card = document.createElement("div");
card.className = "doc-card";
// --- Existing Date and Type Formatting ---
const createdAt = new Date(doc.created_at);
const formattedDate = createdAt.toLocaleString("zh-CN", { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
let docTypeName = "其他";
switch (doc.type) {
case "api": docTypeName = "API文档"; break;
case "requirement": docTypeName = "需求文档"; break;
case "design": docTypeName = "设计文档"; break;
}
const descriptionPreview = doc.description ? doc.description.substring(0, 50) + (doc.description.length > 50 ? '...' : '') : '无描述';
// --- Progress Bar Logic ---
let percent = doc.analysis_percent !== null && doc.analysis_percent !== undefined ? doc.analysis_percent : 0;
percent = Math.max(0, Math.min(100, percent)); // Clamp value between 0 and 100
let progressStatusText = '';
let progressBarClass = ''; // For CSS styling
let errorTitle = ''; // For tooltip on error label
if (doc.analysis_error) {
progressStatusText = `分析错误`;
progressBarClass = 'error';
errorTitle = doc.analysis_error; // Set full error for tooltip
// For errors, show a full red bar to indicate failure after attempt
percent = 100; // Make the bar visually full red
} else if (doc.analysis_completed && percent >= 100) { // Check >= 100 for completeness
progressStatusText = '分析完成';
progressBarClass = 'success';
percent = 100; // Ensure it's exactly 100
} else if (!doc.analysis_completed && percent < 100) {
progressStatusText = `分析中: ${percent}%`;
progressBarClass = 'in-progress'; // Use default blue or specific class
} else {
// Handle other potential states (e.g., completed but < 100 - might indicate an issue)
progressStatusText = `状态异常 (${percent}%)`;
progressBarClass = 'warning';
}
// Construct the progress bar HTML
const progressHtml = `
<div class="card-progress-section">
<span class="progress-label status-${progressBarClass}" title="${errorTitle}">${progressStatusText}</span>
<div class="progress-bar-container">
<div class="progress-bar-fill ${progressBarClass}" style="width: ${percent}%;"></div>
</div>
</div>
`;
// --- End Progress Bar Logic ---
// Assemble card innerHTML, inserting the progressHtml
card.innerHTML = `
<div class="card-content">
<div class="card-header" title="${doc.name}">${doc.name}</div>
<div class="card-meta">
<span>类型: ${docTypeName}</span>
<span>创建于: ${formattedDate}</span>
</div>
${progressHtml}
<div class="card-body">
<p title="${doc.description || ''}">${descriptionPreview}</p>
</div>
</div>
<div class="card-tree-preview-container">
<div class="card-tree-preview-loading" id="tree-preview-loading-${doc.id}">
<div class="mini-spinner"></div> Loading Preview...
</div>
<div class="card-tree-preview" id="tree-preview-${doc.id}"></div>
</div>
<div class="card-actions">
<button class="btn btn-secondary" data-action="view-demands" data-id="${doc.id}">查看需求树</button>
<button class="btn" data-action="edit" data-id="${doc.id}">编辑</button>
<button class="btn btn-danger" data-action="delete" data-id="${doc.id}">删除</button>
</div>
`;
docCardContainer.appendChild(card);
// Fetch and render the preview (existing logic)
fetchAndRenderTreePreview(doc.id);
});
}
// === Rest of your JavaScript code (fetchDocs, modals, saveDoc, deleteDoc, etc.) ===
// ... (Keep other functions as they were) ...
// Make sure convertToD3Hierarchy, renderTreePreview etc. are present
async function fetchTreeDataForDoc(docId) {
if (!docId) return null;
try {
const response = await fetch(`${API_BASE_URL}/demand/${docId}`);
if (!response.ok) {
console.error(`HTTP error ${response.status} fetching tree for doc ${docId}`);
return null;
}
const result = await response.json();
if (result.code === 0 && Array.isArray(result.data)) {
return result.data;
} else {
// If API returns error or empty data for tree, still return null/empty
console.warn(`API error or no tree data for doc ${docId}: ${result.message}`);
return null;
}
} catch (error) {
console.error(`Network error fetching tree for doc ${docId}:`, error);
return null;
}
}
function convertToD3Hierarchy(data) {
if (!Array.isArray(data) || data.length === 0) {
return null; // Return null for empty or invalid data
}
const map = {};
const roots = [];
const hasParent = new Set(); // Keep track of nodes that have a parent
nodeNumber = data.length;
// First pass: create nodes in map
data.forEach(item => {
// Ensure item and item.id are valid before adding to map
if (item && item.id) {
map[item.id] = { ...item, children: [] }; // Create node entry
} else {
console.warn("Skipping invalid item in tree data:", item);
}
});
// Second pass: build hierarchy and track children
data.forEach(item => {
// Ensure item and item.id are valid
if (!item || !item.id || !map[item.id]) {
return; // Skip if item is invalid or wasn't added to map
}
const node = map[item.id];
// Check for valid parent_id and if parent exists in the map
if (item.parent_id && item.parent_id !== '-1' && map[item.parent_id]) {
map[item.parent_id].children.push(node);
hasParent.add(item.id); // Mark this node as having a parent
}
});
// Third pass: identify root nodes (those not marked as having a parent)
data.forEach(item => {
if (item && item.id && map[item.id] && !hasParent.has(item.id)) {
roots.push(map[item.id]);
}
});
if (roots.length === 0) {
// If no roots found (maybe circular references or all nodes have parents listed incorrectly)
// As a fallback, maybe return the first node? Or return null.
console.warn("No root nodes identified in the data.");
// Check if map has any nodes at all
return Object.keys(map).length > 0 ? Object.values(map)[0] : null;
} else if (roots.length === 1) {
return roots[0]; // Single root tree
} else {
// Multiple roots found, create a virtual root
console.log(`Multiple roots (${roots.length}) found, creating virtual root.`);
return {
id: 'virtual-root',
name: 'Virtual Root', // Or maybe derive name from doc?
children: roots,
// Add other necessary fields if your layout depends on them
level: -1 // Indicate it's virtual
};
}
}
function renderTreePreview(containerElement, rootData) {
if (!rootData) {
containerElement.innerHTML = '<p style="text-align:center; color:#cc5500; font-size:12px; margin-top: 40px;">Preview Error: No Root</p>';
return;
}
containerElement.innerHTML = ''; // Clear previous
const bounds = containerElement.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) {
console.warn("Preview container has zero dimensions.");
containerElement.innerHTML = '<p style="text-align:center; color:#cc5500; font-size:12px; margin-top: 40px;">Layout Error</p>';
return;
}
const previewWidth = bounds.width;
const previewHeight = bounds.height;
const nodeSize = { width: 20, height: 8 };
const padding = 5;
const svg = d3.select(containerElement).append("svg")
.attr("width", previewWidth)
.attr("height", previewHeight)
.attr("viewBox", `0 0 ${previewWidth} ${previewHeight}`); // Keep viewBox
// Main group to hold the tree and apply zoom transforms
const g = svg.append("g");
// --- D3 Hierarchy and Layout --- (Keep this part as is)
let root;
try {
root = d3.hierarchy(rootData);
} catch (e) { /* ... error handling ... */ return; }
const treeLayout = d3.tree().nodeSize([nodeSize.height + 6, nodeSize.width + 10]);
try {
treeLayout(root);
} catch (e) { /* ... error handling ... */ return; }
// --- Adjust layout for horizontal view & Find bounds --- (Keep this part as is)
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
root.descendants().forEach(d => {
// const tempY = d.x; d.x = d.y; d.y = tempY;
if (isFinite(d.x) && isFinite(d.y)) {
minX = Math.min(minX, d.x); maxX = Math.max(maxX, d.x);
minY = Math.min(minY, d.y); maxY = Math.max(maxY, d.y);
}
});
if (!isFinite(minX) || !isFinite(maxX) || !isFinite(minY) || !isFinite(maxY)) { minX = -nodeSize.width / 2; maxX = nodeSize.width / 2; minY = -nodeSize.height / 2; maxY = nodeSize.height / 2; }
if (minX === maxX) { minX -= nodeSize.width; maxX += nodeSize.width; }
if (minY === maxY) { minY -= nodeSize.height; maxY += nodeSize.height; }
const treeWidth = maxX - minX;
const treeHeight = maxY - minY;
// --- Render Links --- (Keep this part as is)
g.selectAll('.link').data(root.links()).enter().append('path')
.attr('class', 'link')
.attr('d', d3.linkHorizontal().x(d => d.x).y(d => d.y));
// --- Render Nodes --- (Keep this part as is)
const node = g.selectAll('.node').data(root.descendants()).enter().append('g')
.attr('class', 'node')
.filter(d => d.data.id !== 'virtual-root')
.attr('transform', d => `translate(${d.x || 0},${d.y || 0})`);
node.append('rect')
.attr('width', nodeSize.width).attr('height', nodeSize.height)
.attr('x', -nodeSize.width / 2).attr('y', -nodeSize.height / 2);
// --- Setup D3 Zoom Behavior ---
const zoomBehavior = d3.zoom()
// Set zoom scale limits for the preview
.scaleExtent([0.3, 4]) // Allow zooming out slightly and zooming in a bit
// Optional: Limit panning area (translateExtent) if needed
// .translateExtent([[0, 0], [previewWidth, previewHeight]])
.on("zoom", (event) => {
// Apply the transformation to the main group 'g'
g.attr("transform", event.transform);
});
// Apply the zoom behavior to the SVG element
svg.call(zoomBehavior);
// --- Calculate and Apply Initial Fit/Centering Transform using Zoom ---
let initialTransform = d3.zoomIdentity; // Start with default transform
if (treeWidth > 0 && treeHeight > 0) {
const scale = Math.min(
(previewWidth - padding * 2) / treeWidth,
(previewHeight - padding * 2) / treeHeight,
1.5 // Limit initial zoom-in if tree is tiny
);
if (isFinite(scale) && scale > 0) {
const translateX = (previewWidth / 2) - scale * (minX + treeWidth / 2);
const translateY = (previewHeight / 2) - scale * (minY + treeHeight / 2);
// Create the initial transform object
initialTransform = d3.zoomIdentity.translate(translateX, translateY).scale(scale);
} else {
console.warn("Invalid scale for initial transform:", scale);
// Fallback: Just center if scale is invalid
const translateX = (previewWidth / 2) - (minX + treeWidth / 2);
const translateY = (previewHeight / 2) - (minY + treeHeight / 2);
initialTransform = d3.zoomIdentity.translate(translateX, translateY);
}
} else {
// Fallback for single node or invalid bounds: just center
initialTransform = d3.zoomIdentity.translate(previewWidth / 2, previewHeight / 2);
}
// Apply the calculated initial transform *through the zoom behavior*
// This sets the starting zoom state correctly
svg.call(zoomBehavior.transform, initialTransform);
}
// === Other JS functions (fetchAndRenderTreePreview, renderDocCards, etc.) remain the same ===
// ... Ensure all other functions like fetchDocs, modals, saveDoc, deleteDoc are included ...
// Orchestrator (No changes needed here, but ensure it calls the revised convertToD3Hierarchy)
async function fetchAndRenderTreePreview(docId) {
const treeData = await fetchTreeDataForDoc(docId);
const previewContainer = document.getElementById(`tree-preview-${docId}`);
const loadingIndicator = document.getElementById(`tree-preview-loading-${docId}`);
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
if (previewContainer && treeData && treeData.length > 0) {
// Convert raw data to D3 hierarchy format before rendering
const hierarchyData = convertToD3Hierarchy(treeData); // Uses the revised function
if (hierarchyData) {
renderTreePreview(previewContainer, hierarchyData); // Calls the revised render function
} else {
previewContainer.innerHTML = '<p style="text-align:center; color:#cc5500; font-size:12px; margin-top: 40px;">Preview Error: Invalid data format</p>';
}
} else if (previewContainer) {
previewContainer.innerHTML = '<p style="text-align:center; color:#999; font-size:12px; margin-top: 40px;">No tree preview available</p>';
}
}
function showEmptyState(show, message = "暂无文档数据") {
if (show) {
// No need to hide table as it's always hidden now
emptyStateEl.style.display = "block";
emptyStateEl.querySelector("p").textContent = message;
if (docCardContainer) docCardContainer.style.display = 'none'; // Hide card container
} else {
emptyStateEl.style.display = "none";
if (docCardContainer) docCardContainer.style.display = 'grid'; // Show card container
}
}
// === UI Interaction & CRUD ===
function filterDocs() {
const searchTerm = searchInputEl.value.toLowerCase().trim();
const filtered = docs.filter(doc =>
doc.name.toLowerCase().includes(searchTerm) ||
(doc.description && doc.description.toLowerCase().includes(searchTerm))
);
renderDocCards(filtered);
}
function openDocModal(doc = null) {
modalTitle.textContent = doc ? "编辑文档" : "添加文档";
docForm.reset(); // Always reset first
fileNameEl.textContent = "未选择文件"; // Reset file name display
if (doc) {
docIdInput.value = doc.id;
docNameInput.value = doc.name || "";
docTypeInput.value = doc.type || "other";
docDescInput.value = doc.description || "";
// Don't reset file input, show placeholder for file name
fileNameEl.textContent = doc.file_path ? `当前文件: ${doc.file_path.split('/').pop()}` : "可选择新文件替换";
} else {
docIdInput.value = "";
}
docModal.style.display = "flex"; // Use flex for centering
}
function closeDocModal() {
docModal.style.display = "none";
// Optionally clear form errors here
}
function openConfirmModal(docId) {
currentDocIdToDelete = docId;
confirmModal.style.display = "flex";
}
function closeConfirmModal() {
confirmModal.style.display = "none";
currentDocIdToDelete = null;
}
// Handle clicks on buttons within cards
function handleCardActionClick(event) {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
const id = button.dataset.id;
switch (action) {
case 'view-demands':
window.location.href = `demand.html?doc_id=${id}`;
break;
case 'edit':
const docToEdit = docs.find(d => d.id === id);
if (docToEdit) openDocModal(docToEdit);
break;
case 'delete':
openConfirmModal(id);
break;
}
}
async function saveDoc() {
const docId = docIdInput.value;
const name = docNameInput.value.trim();
if (!name) {
alert("请输入文档名称");
docNameInput.focus();
return;
}
// Show loading indicator
saveDocBtn.disabled = true;
saveDocBtn.textContent = '保存中...';
const description = docDescInput.value.trim();
const type = docTypeInput.value;
try {
const url = docId ? `${API_BASE_URL}/doc/${docId}` : `${API_BASE_URL}/doc/`;
const method = docId ? "PATCH" : "POST";
let response
if (method === "PATCH") {
let formData = {
id: docId,
name: name,
type: type,
description: description,
project_id: projectId
}
console.log("formData", formData)
response = await fetch(url, { method, body: JSON.stringify(formData), headers: { "Content-Type": "application/json" } });
console.log("response", response)
} else {
let formData = new FormData();
formData.append("name", name);
formData.append("type", type);
formData.append("description", description);
formData.append("project_id", projectId);
if (docFileInput.files.length > 0) {
formData.append("file", docFileInput.files[0]);
}
response = await fetch(url, {
method,
body: formData,
});
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: `HTTP error ${response.status}` }));
throw new Error(errorData.message || `HTTP error ${response.status}`);
}
const result = await response.json();
if (result.code === 0) {
alert(docId ? "文档更新成功" : "文档创建成功");
closeDocModal();
fetchDocs(); // Refresh the card list
} else {
alert(`操作失败: ${result.message || "未知错误"}`);
}
} catch (error) {
console.error("保存文档错误:", error);
alert(`操作失败: ${error.message}`);
} finally {
// Hide loading indicator
saveDocBtn.disabled = false;
saveDocBtn.textContent = '保存';
}
}
async function deleteDoc() {
if (!currentDocIdToDelete) return;
// Show loading indicator on button
confirmDeleteBtn.disabled = true;
confirmDeleteBtn.textContent = '删除中...';
try {
const response = await fetch(`${API_BASE_URL}/doc/${currentDocIdToDelete}`, { method: "DELETE" });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: `HTTP error ${response.status}` }));
throw new Error(errorData.message || `HTTP error ${response.status}`);
}
const result = await response.json();
if (result.code === 0) {
alert("文档删除成功");
closeConfirmModal();
fetchDocs(); // Refresh the card list
} else {
alert(`删除失败: ${result.message || "未知错误"}`);
}
} catch (error) {
console.error("删除文档错误:", error);
alert(`删除失败: ${error.message}`);
} finally {
// Hide loading indicator
confirmDeleteBtn.disabled = false;
confirmDeleteBtn.textContent = '删除';
}
}
</script>
</body>
</html>