1345 lines
49 KiB
HTML
1345 lines
49 KiB
HTML
<!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> |