zotero-kb/src/zotero_kb/templates/index.html
2026-04-15 12:54:27 +08:00

979 lines
36 KiB
HTML
Raw 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 lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zotero KB</title>
<style>
:root {
color-scheme: light;
--bg: #f2eee5;
--panel: rgba(255, 250, 242, 0.9);
--ink: #1d221b;
--muted: #56604f;
--line: #d7cdbd;
--accent: #2c6e49;
--accent-strong: #1e4d33;
--surface: #fffdf8;
--danger: #8b3a2a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
background:
radial-gradient(circle at top left, rgba(44, 110, 73, 0.14), transparent 30%),
linear-gradient(180deg, #f7f2e8, var(--bg));
color: var(--ink);
}
body.modal-open {
overflow: hidden;
}
.layout {
display: grid;
grid-template-columns: 20rem 1fr 26rem;
min-height: 100vh;
}
.panel {
padding: 1.25rem;
border-right: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(10px);
overflow: auto;
}
.panel:last-child {
border-right: 0;
}
h1, h2, h3 {
margin: 0 0 0.75rem;
}
p, label, li, small {
color: var(--muted);
line-height: 1.5;
}
form, .stack {
display: grid;
gap: 0.75rem;
}
.section {
margin-top: 1.5rem;
}
.surface {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 18px;
padding: 1rem;
box-shadow: 0 10px 30px rgba(68, 58, 36, 0.06);
}
input, textarea, select, button {
width: 100%;
font: inherit;
}
input, textarea, select {
padding: 0.78rem 0.9rem;
border-radius: 12px;
border: 1px solid var(--line);
background: #fffdf9;
color: var(--ink);
}
textarea {
min-height: 9rem;
resize: vertical;
}
button {
border: 0;
border-radius: 999px;
padding: 0.78rem 1rem;
cursor: pointer;
background: var(--accent);
color: #f5f7ef;
font-weight: 600;
transition: transform 140ms ease, background 140ms ease;
}
button:hover {
transform: translateY(-1px);
background: var(--accent-strong);
}
button.secondary {
background: rgba(44, 110, 73, 0.12);
color: var(--accent-strong);
}
button.danger {
background: rgba(139, 58, 42, 0.12);
color: var(--danger);
}
.project-list, .card-list, .result-list, .tree-list, .collection-items-list {
display: grid;
gap: 0.75rem;
}
.project-item, .card-item, .result-item {
border: 1px solid var(--line);
border-radius: 16px;
padding: 0.9rem;
background: rgba(255, 255, 255, 0.65);
}
.project-item.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(44, 110, 73, 0.15);
}
.row {
display: flex;
gap: 0.6rem;
align-items: center;
}
.row > * {
flex: 1;
}
.meta {
font-size: 0.9rem;
color: var(--muted);
}
.status {
min-height: 1.4rem;
font-size: 0.95rem;
color: var(--accent-strong);
}
.status.error {
color: var(--danger);
}
.empty {
padding: 1rem;
border: 1px dashed var(--line);
border-radius: 16px;
color: var(--muted);
}
.modal-shell[hidden] {
display: none;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 40;
}
.modal-overlay {
position: absolute;
inset: 0;
border-radius: 0;
background: rgba(24, 28, 22, 0.42);
}
.modal-card {
position: relative;
z-index: 1;
width: min(1100px, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
margin: 1rem auto;
display: grid;
grid-template-rows: auto auto 1fr auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 24px 60px rgba(35, 31, 21, 0.2);
overflow: hidden;
}
.modal-header,
.modal-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.modal-action-bar {
border-top: 1px solid var(--line);
border-bottom: 0;
position: sticky;
bottom: 0;
z-index: 2;
background: var(--surface);
}
.modal-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.modal-body {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.2fr);
min-height: 24rem;
}
.modal-pane {
display: grid;
gap: 0.6rem;
padding: 1rem 1.25rem;
min-height: 0;
}
.modal-scroll {
min-height: 0;
overflow: auto;
}
.preview-popover {
position: absolute;
max-width: 28rem;
padding: 0.85rem 1rem;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 253, 248, 0.98);
box-shadow: 0 18px 40px rgba(35, 31, 21, 0.16);
color: var(--ink);
}
.tabs {
display: flex;
gap: 0.5rem;
}
.tabs button {
background: rgba(44, 110, 73, 0.08);
color: var(--accent-strong);
}
.tabs button.active {
background: var(--accent);
color: #f5f7ef;
}
.tab-panel[hidden] {
display: none;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
.panel {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.modal-card {
width: calc(100vw - 1rem);
margin: 0.5rem auto;
max-height: calc(100vh - 1rem);
}
.modal-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(12rem, 1fr) minmax(12rem, 1fr);
}
.modal-header,
.modal-action-bar {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<main class="layout">
<section class="panel">
<h1>Zotero KB</h1>
<p>创建项目,按 Zotero collection 树批量导入文献,再在项目范围内做引用推荐与引用方案。</p>
<div class="section surface">
<h3>创建项目</h3>
<form id="create-project-form" class="stack">
<input id="project-id" name="project_id" placeholder="project-id例如 thesis-ch2" required />
<input id="project-name" name="name" placeholder="项目名称" required />
<div class="row">
<input id="llm-provider" name="llm_provider" value="deepseek" />
<input id="llm-model" name="llm_model" value="deepseek-chat" />
</div>
<button type="submit">创建项目</button>
</form>
<div id="project-status" class="status"></div>
</div>
<div class="section surface">
<div class="row" style="justify-content: space-between">
<h3>项目列表</h3>
<button id="refresh-projects-button" type="button" class="secondary">刷新</button>
</div>
<div id="project-list" class="project-list"></div>
</div>
<div class="section surface">
<h3>导入文献</h3>
<p class="meta">打开子界面浏览 Zotero collection 树,再批量导入到当前项目。</p>
<button id="open-import-modal-button" type="button">导入文献</button>
</div>
<div id="import-modal" class="modal-shell" hidden aria-hidden="true">
<button id="import-modal-overlay" class="modal-overlay" type="button" aria-label="关闭导入弹窗"></button>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<header class="modal-header">
<div>
<h3 id="import-modal-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p>
</div>
<div class="modal-header-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div>
<button id="close-import-modal-button" type="button" class="secondary">关闭</button>
</div>
</header>
<div id="collection-status" class="status"></div>
<div class="modal-body">
<div class="modal-pane">
<small>Collections</small>
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
</div>
<div class="modal-pane">
<small>Collection Items</small>
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
</div>
</div>
<footer class="modal-action-bar">
<button id="modal-select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选到当前项目</button>
</footer>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
</section>
<section class="panel">
<div class="row" style="justify-content: space-between">
<div>
<h2>Cards</h2>
<p id="current-project-label">当前未选择项目。</p>
</div>
<button id="reload-cards-button" type="button" class="secondary">刷新卡片</button>
</div>
<div id="card-list" class="card-list"></div>
<div class="section surface">
<h3>卡片详情</h3>
<div id="card-detail" class="empty">点击卡片后这里会显示摘要、claims 和可引用内容。</div>
</div>
</section>
<section class="panel">
<h2>Writing</h2>
<p>两个入口独立:候选引用推荐和初版引用方案。</p>
<div class="tabs">
<button id="recommend-tab" type="button" class="active">引用推荐</button>
<button id="plan-tab" type="button">引用方案</button>
</div>
<section id="recommend-panel" class="tab-panel section surface">
<form id="recommend-form" class="stack">
<textarea id="recommend-prompt" placeholder="输入本段写作意图或草稿"></textarea>
<button type="submit">生成候选引用</button>
</form>
<div id="recommend-status" class="status"></div>
<div id="recommend-results" class="result-list"></div>
</section>
<section id="plan-panel" class="tab-panel section surface" hidden>
<form id="plan-form" class="stack">
<textarea id="plan-prompt" placeholder="输入本段写作意图"></textarea>
<button type="submit">生成初版引用方案</button>
</form>
<div id="plan-status" class="status"></div>
<div id="plan-results" class="result-list"></div>
</section>
</section>
</main>
<script>
const ABSTRACT_PREVIEW_DELAY_MS = 3000;
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
function readImportSessionState() {
try {
const raw = window.sessionStorage.getItem(IMPORT_SESSION_STORAGE_KEY);
if (!raw) {
return {};
}
const payload = JSON.parse(raw);
return typeof payload === "object" && payload ? payload : {};
} catch (_error) {
return {};
}
}
const importSessionState = readImportSessionState();
const state = {
projects: [],
currentProjectId: null,
currentCards: [],
collectionTree: [],
isImportModalOpen: false,
selectedCollectionKey: typeof importSessionState.selectedCollectionKey === "string"
? importSessionState.selectedCollectionKey
: null,
expandedCollectionKeys: new Set(
Array.isArray(importSessionState.expandedCollectionKeys)
? importSessionState.expandedCollectionKeys.filter((value) => typeof value === "string")
: []
),
selectedItemKeys: new Set(
Array.isArray(importSessionState.selectedItemKeys)
? importSessionState.selectedItemKeys.filter((value) => typeof value === "string")
: []
),
visibleCollectionItems: [],
};
const elements = {
projectForm: document.getElementById("create-project-form"),
projectStatus: document.getElementById("project-status"),
projectList: document.getElementById("project-list"),
importModal: document.getElementById("import-modal"),
importModalOverlay: document.getElementById("import-modal-overlay"),
openImportModalButton: document.getElementById("open-import-modal-button"),
closeImportModalButton: document.getElementById("close-import-modal-button"),
importModalProjectLabel: document.getElementById("import-modal-project-label"),
collectionStatus: document.getElementById("collection-status"),
collectionTree: document.getElementById("modal-collection-tree"),
collectionItems: document.getElementById("modal-collection-items"),
selectedCount: document.getElementById("modal-selected-count"),
selectDescendantsButton: document.getElementById("modal-select-descendants-button"),
clearSelectionButton: document.getElementById("modal-clear-selection-button"),
importSelectedItemsButton: document.getElementById("modal-import-selected-items-button"),
previewPopover: document.getElementById("item-preview-popover"),
currentProjectLabel: document.getElementById("current-project-label"),
cardList: document.getElementById("card-list"),
cardDetail: document.getElementById("card-detail"),
reloadCardsButton: document.getElementById("reload-cards-button"),
refreshProjectsButton: document.getElementById("refresh-projects-button"),
recommendForm: document.getElementById("recommend-form"),
recommendStatus: document.getElementById("recommend-status"),
recommendResults: document.getElementById("recommend-results"),
planForm: document.getElementById("plan-form"),
planStatus: document.getElementById("plan-status"),
planResults: document.getElementById("plan-results"),
recommendTab: document.getElementById("recommend-tab"),
planTab: document.getElementById("plan-tab"),
recommendPanel: document.getElementById("recommend-panel"),
planPanel: document.getElementById("plan-panel"),
};
function persistImportSessionState() {
window.sessionStorage.setItem(
IMPORT_SESSION_STORAGE_KEY,
JSON.stringify({
selectedCollectionKey: state.selectedCollectionKey,
expandedCollectionKeys: Array.from(state.expandedCollectionKeys),
selectedItemKeys: Array.from(state.selectedItemKeys),
})
);
}
function setStatus(target, message, isError = false) {
target.textContent = message || "";
target.classList.toggle("error", Boolean(isError));
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(payload.detail || response.statusText);
}
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return response.json();
}
return response.text();
}
function renderProjects() {
if (!state.projects.length) {
elements.projectList.innerHTML = '<div class="empty">还没有项目。先在上方创建一个。</div>';
return;
}
elements.projectList.innerHTML = "";
for (const project of state.projects) {
const item = document.createElement("button");
item.type = "button";
item.className = "project-item" + (project.id === state.currentProjectId ? " active" : "");
item.innerHTML = `
<strong>${project.name}</strong>
<div class="meta">${project.id}</div>
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
`;
item.addEventListener("click", () => selectProject(project.id));
elements.projectList.appendChild(item);
}
}
function renderCards(cards) {
state.currentCards = cards || [];
if (!state.currentCards.length) {
elements.cardList.innerHTML = '<div class="empty">当前项目还没有卡片。先导入 Zotero 选中文献。</div>';
elements.cardDetail.innerHTML = '<div class="empty">导入后点击卡片查看详情。</div>';
return;
}
elements.cardList.innerHTML = "";
for (const card of state.currentCards) {
const item = document.createElement("div");
item.className = "card-item";
item.innerHTML = `
<strong>${card.title}</strong>
<div class="meta">${card.item_key}</div>
<p>${card.summary || "暂无摘要"}</p>
<div class="row">
<button type="button" class="secondary">查看详情</button>
<button type="button" class="danger">移出项目</button>
</div>
`;
const [detailButton, removeButton] = item.querySelectorAll("button");
detailButton.addEventListener("click", () => showCardDetail(card));
removeButton.addEventListener("click", () => removeCard(card.item_key));
elements.cardList.appendChild(item);
}
}
function showCardDetail(card) {
const claims = (card.claims || []).map((claim) => `<li>${claim}</li>`).join("") || "<li>暂无 claims</li>";
const quotes = (card.quotable_spans || []).map((quote) => `<li>${quote}</li>`).join("") || "<li>暂无可引用片段</li>";
elements.cardDetail.innerHTML = `
<strong>${card.title}</strong>
<div class="meta">${card.item_key}</div>
<p>${card.summary || "暂无摘要"}</p>
<h4>Claims</h4>
<ul>${claims}</ul>
<h4>Quotable</h4>
<ul>${quotes}</ul>
`;
}
function renderRecommendationResults(results) {
if (!results.length) {
elements.recommendResults.innerHTML = '<div class="empty">没有匹配到候选引用。</div>';
return;
}
elements.recommendResults.innerHTML = results.map((item) => `
<div class="result-item">
<strong>${item.title}</strong>
<div class="meta">${item.item_key}</div>
<p>${item.why_relevant}</p>
<pre>${(item.claims || []).join("\n")}</pre>
</div>
`).join("");
}
function renderPlanResults(sections) {
if (!sections.length) {
elements.planResults.innerHTML = '<div class="empty">没有生成方案。</div>';
return;
}
elements.planResults.innerHTML = sections.map((section) => `
<div class="result-item">
<strong>${section.heading}</strong>
<p>${section.goal}</p>
<div class="meta">${(section.citations || []).map((item) => `${item.title} (${item.role})`).join(" · ")}</div>
<pre>${(section.notes || []).join("\n")}</pre>
</div>
`).join("");
}
function renderSelectedCount() {
elements.selectedCount.textContent = `已选 ${state.selectedItemKeys.size}`;
}
function updateImportActionState() {
const hasProject = Boolean(state.currentProjectId);
const hasSelection = state.selectedItemKeys.size > 0;
elements.importModalProjectLabel.textContent = hasProject ? `当前项目:${state.currentProjectId}` : "未选择项目";
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection;
elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献");
elements.selectDescendantsButton.disabled = !state.visibleCollectionItems.length;
elements.clearSelectionButton.disabled = !state.selectedItemKeys.size;
renderSelectedCount();
}
function collectCollectionParents(nodes, parentKey = null, lookup = new Map()) {
for (const node of nodes) {
lookup.set(node.collection_key, parentKey);
collectCollectionParents(node.children || [], node.collection_key, lookup);
}
return lookup;
}
function ensureSelectedCollectionExpanded() {
if (!state.selectedCollectionKey) {
return;
}
const parentLookup = collectCollectionParents(state.collectionTree);
let currentKey = parentLookup.get(state.selectedCollectionKey) || null;
while (currentKey) {
state.expandedCollectionKeys.add(currentKey);
currentKey = parentLookup.get(currentKey) || null;
}
}
function renderCollectionTree() {
if (!state.collectionTree.length) {
elements.collectionTree.innerHTML = '<div class="empty">暂无 collection。</div>';
return;
}
elements.collectionTree.innerHTML = "";
const fragment = document.createDocumentFragment();
function appendNode(node, depth) {
const wrapper = document.createElement("div");
wrapper.style.display = "grid";
wrapper.style.gap = "0.35rem";
wrapper.style.marginLeft = `${depth * 0.9}rem`;
const row = document.createElement("div");
row.className = "row";
row.style.alignItems = "stretch";
row.style.gap = "0.45rem";
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
if (hasChildren) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "secondary";
toggle.style.flex = "0 0 auto";
toggle.style.width = "auto";
toggle.style.padding = "0.55rem 0.75rem";
toggle.textContent = state.expandedCollectionKeys.has(node.collection_key) ? "" : "+";
toggle.addEventListener("click", () => {
if (state.expandedCollectionKeys.has(node.collection_key)) {
state.expandedCollectionKeys.delete(node.collection_key);
} else {
state.expandedCollectionKeys.add(node.collection_key);
}
persistImportSessionState();
renderCollectionTree();
});
row.appendChild(toggle);
}
const button = document.createElement("button");
button.type = "button";
button.className = "project-item" + (node.collection_key === state.selectedCollectionKey ? " active" : "");
button.style.textAlign = "left";
button.innerHTML = `
<strong>${node.name}</strong>
<div class="meta">${node.direct_item_count} 篇直接文献 · ${node.descendant_item_count} 篇含子目录</div>
`;
button.addEventListener("click", () => {
loadCollectionItems(node.collection_key).catch((error) => {
setStatus(elements.collectionStatus, error.message, true);
});
});
row.appendChild(button);
wrapper.appendChild(row);
fragment.appendChild(wrapper);
if (hasChildren && state.expandedCollectionKeys.has(node.collection_key)) {
for (const child of node.children) {
appendNode(child, depth + 1);
}
}
}
for (const node of state.collectionTree) {
appendNode(node, 0);
}
elements.collectionTree.appendChild(fragment);
}
function renderCollectionItems() {
if (!state.selectedCollectionKey) {
elements.collectionItems.innerHTML = '<div class="empty">先选择一个 collection。</div>';
updateImportActionState();
return;
}
if (!state.visibleCollectionItems.length) {
elements.collectionItems.innerHTML = '<div class="empty">当前目录及子目录下没有可导入文献。</div>';
updateImportActionState();
return;
}
elements.collectionItems.innerHTML = "";
for (const item of state.visibleCollectionItems) {
const row = document.createElement("label");
row.className = "project-item";
row.style.display = "grid";
row.style.gridTemplateColumns = "auto 1fr";
row.style.gap = "0.75rem";
row.style.alignItems = "start";
row.innerHTML = `
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div>
<strong>${item.title}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
<p>${item.abstract || "暂无摘要"}</p>
</div>
`;
row.querySelector("input").addEventListener("change", () => toggleItemSelection(item.item_key));
elements.collectionItems.appendChild(row);
}
updateImportActionState();
}
async function loadCollectionTree() {
setStatus(elements.collectionStatus, "正在加载 collection...");
const payload = await api("/api/zotero/collections/tree");
state.collectionTree = Array.isArray(payload.collections) ? payload.collections : [];
if (!state.selectedCollectionKey && state.collectionTree.length) {
state.selectedCollectionKey = state.collectionTree[0].collection_key;
}
ensureSelectedCollectionExpanded();
persistImportSessionState();
renderCollectionTree();
if (state.selectedCollectionKey) {
await loadCollectionItems(state.selectedCollectionKey);
return;
}
state.visibleCollectionItems = [];
renderCollectionItems();
setStatus(elements.collectionStatus, "");
}
async function loadCollectionItems(collectionKey) {
state.selectedCollectionKey = collectionKey;
ensureSelectedCollectionExpanded();
persistImportSessionState();
renderCollectionTree();
setStatus(elements.collectionStatus, "正在加载文献...");
const payload = await api(`/api/zotero/collections/${collectionKey}/items`);
state.visibleCollectionItems = Array.isArray(payload.items) ? payload.items : [];
renderCollectionItems();
const visibleCount = state.visibleCollectionItems.length;
setStatus(
elements.collectionStatus,
visibleCount ? `当前目录及子目录共 ${visibleCount} 篇文献。` : "当前目录及子目录下没有可导入文献。"
);
}
function toggleItemSelection(itemKey) {
if (state.selectedItemKeys.has(itemKey)) {
state.selectedItemKeys.delete(itemKey);
} else {
state.selectedItemKeys.add(itemKey);
}
persistImportSessionState();
renderCollectionItems();
updateImportActionState();
}
function selectVisibleItems() {
for (const item of state.visibleCollectionItems) {
state.selectedItemKeys.add(item.item_key);
}
persistImportSessionState();
renderCollectionItems();
updateImportActionState();
}
function clearSelectedItems() {
state.selectedItemKeys.clear();
persistImportSessionState();
renderCollectionItems();
updateImportActionState();
}
async function importSelectedItems() {
if (!state.currentProjectId || !state.selectedItemKeys.size) {
updateImportActionState();
return;
}
setStatus(elements.collectionStatus, "正在导入...");
const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, {
method: "POST",
body: JSON.stringify({ item_keys: Array.from(state.selectedItemKeys) }),
});
state.selectedItemKeys.clear();
persistImportSessionState();
renderCollectionItems();
renderCards(payload.project_view?.cards || []);
setStatus(elements.collectionStatus, "导入完成。");
updateImportActionState();
closeImportModal();
}
async function openImportModal() {
state.isImportModalOpen = true;
elements.importModal.hidden = false;
elements.importModal.setAttribute("aria-hidden", "false");
document.body.classList.toggle("modal-open", true);
elements.previewPopover.hidden = true;
elements.previewPopover.textContent = "";
updateImportActionState();
if (!state.collectionTree.length) {
try {
await loadCollectionTree();
} catch (error) {
setStatus(elements.collectionStatus, error.message, true);
}
} else if (state.selectedCollectionKey && !state.visibleCollectionItems.length) {
try {
await loadCollectionItems(state.selectedCollectionKey);
} catch (error) {
setStatus(elements.collectionStatus, error.message, true);
}
} else {
renderCollectionTree();
renderCollectionItems();
}
}
function closeImportModal() {
state.isImportModalOpen = false;
elements.importModal.hidden = true;
elements.importModal.setAttribute("aria-hidden", "true");
document.body.classList.toggle("modal-open", false);
elements.previewPopover.hidden = true;
elements.previewPopover.textContent = "";
}
async function loadProjects() {
const projects = await api("/api/projects");
state.projects = projects;
if (state.currentProjectId && !projects.some((project) => project.id === state.currentProjectId)) {
state.currentProjectId = projects.length ? projects[0].id : null;
}
if (!state.currentProjectId && projects.length) {
state.currentProjectId = projects[0].id;
}
renderProjects();
updateImportActionState();
if (state.currentProjectId) {
await selectProject(state.currentProjectId, false);
return;
}
elements.currentProjectLabel.textContent = "当前未选择项目。";
renderCards([]);
}
async function selectProject(projectId, rerender = true) {
state.currentProjectId = projectId;
elements.currentProjectLabel.textContent = `当前项目:${projectId}`;
updateImportActionState();
if (rerender) {
renderProjects();
}
const payload = await api(`/api/projects/${projectId}/cards`);
renderCards(payload.cards || []);
}
async function removeCard(itemKey) {
if (!state.currentProjectId) {
return;
}
await api(`/api/projects/${state.currentProjectId}/items/${itemKey}`, { method: "DELETE" });
await selectProject(state.currentProjectId);
}
elements.projectForm.addEventListener("submit", async (event) => {
event.preventDefault();
const formData = new FormData(elements.projectForm);
try {
await api("/api/projects", {
method: "POST",
body: JSON.stringify(Object.fromEntries(formData.entries())),
});
setStatus(elements.projectStatus, "项目已创建。");
elements.projectForm.reset();
document.getElementById("llm-provider").value = "deepseek";
document.getElementById("llm-model").value = "deepseek-chat";
await loadProjects();
} catch (error) {
setStatus(elements.projectStatus, error.message, true);
}
});
elements.reloadCardsButton.addEventListener("click", async () => {
if (!state.currentProjectId) {
return;
}
await selectProject(state.currentProjectId);
});
elements.refreshProjectsButton.addEventListener("click", loadProjects);
elements.openImportModalButton.addEventListener("click", () => {
openImportModal();
});
elements.closeImportModalButton.addEventListener("click", closeImportModal);
elements.importModalOverlay.addEventListener("click", closeImportModal);
elements.selectDescendantsButton.addEventListener("click", selectVisibleItems);
elements.clearSelectionButton.addEventListener("click", clearSelectedItems);
elements.importSelectedItemsButton.addEventListener("click", () => {
importSelectedItems().catch((error) => {
setStatus(elements.collectionStatus, error.message, true);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportModalOpen) {
closeImportModal();
}
});
elements.recommendForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.currentProjectId) {
setStatus(elements.recommendStatus, "请先选择项目。", true);
return;
}
const prompt = document.getElementById("recommend-prompt").value.trim();
try {
const payload = await api(`/api/projects/${state.currentProjectId}/writing/recommend-citations`, {
method: "POST",
body: JSON.stringify({ prompt }),
});
const results = Array.isArray(payload.results) ? payload.results : [];
setStatus(elements.recommendStatus, `返回 ${results.length} 条候选引用。`);
renderRecommendationResults(results);
} catch (error) {
setStatus(elements.recommendStatus, error.message, true);
}
});
elements.planForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.currentProjectId) {
setStatus(elements.planStatus, "请先选择项目。", true);
return;
}
const prompt = document.getElementById("plan-prompt").value.trim();
try {
const payload = await api(`/api/projects/${state.currentProjectId}/writing/generate-plan`, {
method: "POST",
body: JSON.stringify({ prompt }),
});
const sections = Array.isArray(payload.sections) ? payload.sections : [];
setStatus(elements.planStatus, `生成 ${sections.length} 个方案段落。`);
renderPlanResults(sections);
} catch (error) {
setStatus(elements.planStatus, error.message, true);
}
});
function activateTab(name) {
const isRecommend = name === "recommend";
elements.recommendTab.classList.toggle("active", isRecommend);
elements.planTab.classList.toggle("active", !isRecommend);
elements.recommendPanel.hidden = !isRecommend;
elements.planPanel.hidden = isRecommend;
}
elements.recommendTab.addEventListener("click", () => activateTab("recommend"));
elements.planTab.addEventListener("click", () => activateTab("plan"));
closeImportModal();
updateImportActionState();
loadProjects().catch((error) => {
setStatus(elements.projectStatus, error.message, true);
});
</script>
</body>
</html>