zotero-kb/src/zotero_kb/templates/index.html
Saberlve 35d1db4fd7 feat(import-window): add window state management to JS
Adds WINDOW_SESSION_STORAGE_KEY, readWindowSessionState(),
window state properties to state object, new element references
in elements object, and persistWindowSessionState().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 15:53:51 +08:00

1150 lines
43 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);
}
.window-shell[hidden] {
display: none;
}
.window-shell {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
}
.window-shell:not([hidden]) {
pointer-events: auto;
}
.window-frame {
position: fixed;
z-index: 40;
width: var(--fw-width, 90vw);
height: var(--fw-height, 75vh);
left: var(--fw-left, calc((100vw - var(--fw-width, 90vw)) / 2));
top: var(--fw-top, calc((100vh - var(--fw-height, 75vh)) / 2));
min-width: 40rem;
min-height: 30rem;
border-radius: 16px;
box-shadow: 0 24px 64px rgba(35, 31, 21, 0.22);
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--line);
}
.window-frame.maximized {
width: 100vw;
height: 100vh;
left: 0 !important;
top: 0 !important;
border-radius: 0;
--fw-width: 100vw;
--fw-height: 100vh;
}
.window-frame:not(.maximized):not(.minimized) {
resize: both;
overflow: auto;
}
.window-minimized-bar {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 50;
height: 2.5rem;
width: 14rem;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--line);
box-shadow: 0 8px 24px rgba(35, 31, 21, 0.14);
display: none;
align-items: center;
padding: 0 0.75rem;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
color: var(--ink);
}
.window-minimized-bar:not([hidden]) {
display: flex;
}
.window-minimized-bar:hover {
background: var(--panel);
}
.window-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--line);
background: rgba(255, 253, 248, 0.98);
cursor: grab;
}
.window-toolbar:active {
cursor: grabbing;
}
.window-toolbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.window-body {
display: grid;
grid-template-columns: 22rem minmax(0, 1fr);
min-height: 0;
}
.window-pane {
display: grid;
gap: 0.6rem;
min-height: 0;
padding: 1rem 1.25rem;
}
.window-pane.collections {
border-right: 1px solid var(--line);
}
.window-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-window-shell" class="window-shell" hidden aria-hidden="true">
<section id="import-window" class="window-frame" role="dialog" aria-modal="true" aria-labelledby="import-window-title">
<div class="window-toolbar" id="import-window-toolbar">
<div style="display:flex;align-items:center;gap:0.6rem">
<span style="font-size:1.2rem;line-height:1"></span>
<div>
<h3 id="import-window-title">Import From Zotero</h3>
<p id="import-window-project-label" class="meta">未选择项目</p>
</div>
</div>
<div class="window-toolbar-actions">
<div id="window-selected-count" class="meta">已选 0 篇</div>
<button id="window-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="window-import-selected-items-button" type="button">导入所选</button>
<button id="window-minimize-button" type="button" class="secondary" title="最小化" style="width:auto;padding:0.55rem 0.75rem">_</button>
<button id="window-maximize-button" type="button" class="secondary" title="最大化" style="width:auto;padding:0.55rem 0.75rem"></button>
<button id="window-close-button" type="button" class="secondary" title="关闭" style="width:auto;padding:0.55rem 0.75rem">×</button>
</div>
</div>
<div class="window-body">
<div class="window-pane collections">
<small>Collections</small>
<div id="window-collection-status" class="status"></div>
<div id="window-collection-tree" class="window-scroll tree-list"></div>
</div>
<div class="window-pane items">
<small>Collection Items</small>
<div id="window-collection-items" class="window-scroll collection-items-list"></div>
</div>
</div>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
<div id="import-window-minimized-bar" class="window-minimized-bar" hidden>
<span style="flex:1;text-align:left">导入文献</span>
<button id="window-restore-button" type="button" class="secondary" style="width:auto;padding:0.3rem 0.5rem;font-size:0.8rem"></button>
</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 WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window";
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
function readWindowSessionState() {
try {
const raw = window.sessionStorage.getItem(WINDOW_SESSION_STORAGE_KEY);
if (!raw) return {};
const payload = JSON.parse(raw);
return typeof payload === "object" && payload ? payload : {};
} catch (_error) {
return {};
}
}
const windowSessionState = readWindowSessionState();
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: [],
isImportWindowOpen: false,
isImportModalOpen: false,
windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null,
windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null,
windowWidth: typeof windowSessionState.width === "number" ? windowSessionState.width : null,
windowHeight: typeof windowSessionState.height === "number" ? windowSessionState.height : null,
isWindowMinimized: Boolean(windowSessionState.isMinimized),
isWindowMaximized: Boolean(windowSessionState.isMaximized),
isWindowDragging: false,
windowDragStartX: 0,
windowDragStartY: 0,
windowDragStartLeft: 0,
windowDragStartTop: 0,
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: [],
collectionItemsLoaded: false,
loadingCollectionKey: null,
nextCollectionItemsRequestToken: 0,
activeCollectionItemsRequestToken: 0,
};
const elements = {
projectForm: document.getElementById("create-project-form"),
projectStatus: document.getElementById("project-status"),
projectList: document.getElementById("project-list"),
importWindowShell: document.getElementById("import-window-shell"),
importWindow: document.getElementById("import-window"),
windowToolbar: document.getElementById("import-window-toolbar"),
openImportModalButton: document.getElementById("open-import-modal-button"),
windowMinimizeButton: document.getElementById("window-minimize-button"),
windowMaximizeButton: document.getElementById("window-maximize-button"),
windowCloseButton: document.getElementById("window-close-button"),
windowRestoreButton: document.getElementById("window-restore-button"),
importWindowMinimizedBar: document.getElementById("import-window-minimized-bar"),
windowProjectLabel: document.getElementById("import-window-project-label"),
windowSelectedCount: document.getElementById("window-selected-count"),
windowClearSelectionButton: document.getElementById("window-clear-selection-button"),
windowImportSelectedItemsButton: document.getElementById("window-import-selected-items-button"),
windowCollectionStatus: document.getElementById("window-collection-status"),
windowCollectionTree: document.getElementById("window-collection-tree"),
windowCollectionItems: document.getElementById("window-collection-items"),
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 persistWindowSessionState() {
const fw = elements.importWindow;
if (!fw) return;
const style = window.getComputedStyle(fw);
const width = parseFloat(style.width);
const height = parseFloat(style.height);
const left = parseFloat(style.left);
const top = parseFloat(style.top);
window.sessionStorage.setItem(
WINDOW_SESSION_STORAGE_KEY,
JSON.stringify({
x: left,
y: top,
width: width,
height: height,
isMinimized: state.isWindowMinimized,
isMaximized: state.isWindowMaximized,
})
);
}
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 resetCollectionItemsLoadingState(clearItems = false) {
if (clearItems) {
state.visibleCollectionItems = [];
}
state.collectionItemsLoaded = false;
state.loadingCollectionKey = null;
state.activeCollectionItemsRequestToken = 0;
}
function syncCurrentProjectLabels() {
const currentProject = state.projects.find((project) => project.id === state.currentProjectId) || null;
if (currentProject) {
const label = `当前项目:${currentProject.name}`;
elements.currentProjectLabel.textContent = label;
elements.importModalProjectLabel.textContent = label;
return;
}
elements.currentProjectLabel.textContent = "当前未选择项目。";
elements.importModalProjectLabel.textContent = "未选择项目";
}
function updateImportActionState() {
const hasProject = Boolean(state.currentProjectId);
const hasSelection = state.selectedItemKeys.size > 0;
syncCurrentProjectLabels();
elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection;
elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献");
elements.selectDescendantsButton.disabled = !state.collectionItemsLoaded || !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 normalizeSelectedCollectionKey() {
if (!state.collectionTree.length) {
state.selectedCollectionKey = null;
state.expandedCollectionKeys.clear();
persistImportSessionState();
return null;
}
const parentLookup = collectCollectionParents(state.collectionTree);
if (!state.selectedCollectionKey || !parentLookup.has(state.selectedCollectionKey)) {
state.selectedCollectionKey = state.collectionTree[0].collection_key;
}
state.expandedCollectionKeys = new Set(
Array.from(state.expandedCollectionKeys).filter((key) => parentLookup.has(key))
);
persistImportSessionState();
return state.selectedCollectionKey;
}
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.collectionItemsLoaded) {
elements.collectionItems.innerHTML = state.loadingCollectionKey === state.selectedCollectionKey
? '<div class="empty">正在加载当前 collection 文献。</div>'
: '<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>
</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 : [];
normalizeSelectedCollectionKey();
ensureSelectedCollectionExpanded();
persistImportSessionState();
renderCollectionTree();
if (state.selectedCollectionKey) {
await loadCollectionItems(state.selectedCollectionKey);
return;
}
state.visibleCollectionItems = [];
resetCollectionItemsLoadingState();
renderCollectionItems();
setStatus(elements.collectionStatus, "");
}
async function loadCollectionItems(collectionKey) {
if (!state.collectionItemsLoaded && state.loadingCollectionKey === collectionKey) {
return;
}
state.selectedCollectionKey = collectionKey;
if (normalizeSelectedCollectionKey() !== collectionKey) {
resetCollectionItemsLoadingState(true);
renderCollectionTree();
renderCollectionItems();
setStatus(elements.collectionStatus, state.selectedCollectionKey ? "已切换到可用 collection。" : "");
if (state.selectedCollectionKey) {
await loadCollectionItems(state.selectedCollectionKey);
}
return;
}
ensureSelectedCollectionExpanded();
persistImportSessionState();
renderCollectionTree();
resetCollectionItemsLoadingState();
state.loadingCollectionKey = collectionKey;
const requestToken = state.nextCollectionItemsRequestToken + 1;
state.nextCollectionItemsRequestToken = requestToken;
state.activeCollectionItemsRequestToken = requestToken;
renderCollectionItems();
setStatus(elements.collectionStatus, "正在加载文献...");
try {
const payload = await api(`/api/zotero/collections/${collectionKey}/items`);
if (state.activeCollectionItemsRequestToken !== requestToken || state.selectedCollectionKey !== collectionKey) {
return;
}
state.visibleCollectionItems = Array.isArray(payload.items) ? payload.items : [];
state.collectionItemsLoaded = true;
state.loadingCollectionKey = null;
renderCollectionItems();
const visibleCount = state.visibleCollectionItems.length;
setStatus(
elements.collectionStatus,
visibleCount ? `当前目录及子目录共 ${visibleCount} 篇文献。` : "当前目录及子目录下没有可导入文献。"
);
} catch (error) {
if (state.activeCollectionItemsRequestToken === requestToken) {
resetCollectionItemsLoadingState(true);
renderCollectionItems();
}
throw error;
}
}
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);
updateImportActionState();
if (!state.collectionTree.length) {
try {
await loadCollectionTree();
} catch (error) {
setStatus(elements.collectionStatus, error.message, true);
}
} else if (
state.selectedCollectionKey &&
(!state.collectionItemsLoaded && state.loadingCollectionKey !== state.selectedCollectionKey)
) {
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);
}
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;
}
renderCards([]);
}
async function selectProject(projectId, rerender = true) {
state.currentProjectId = 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>