feat: add zotero import modal shell
This commit is contained in:
parent
f7554b889a
commit
6a2250ef1a
868
src/zotero_kb/templates/index.html
Normal file
868
src/zotero_kb/templates/index.html
Normal file
@ -0,0 +1,868 @@
|
||||
<!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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.tree-node {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.tree-toggle {
|
||||
width: 1.6rem;
|
||||
min-width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(44, 110, 73, 0.08);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.tree-label {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: var(--ink);
|
||||
}
|
||||
.tree-label.active {
|
||||
border-color: rgba(44, 110, 73, 0.22);
|
||||
background: rgba(44, 110, 73, 0.12);
|
||||
}
|
||||
.tree-children {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin-left: 1rem;
|
||||
padding-left: 0.6rem;
|
||||
border-left: 1px dashed var(--line);
|
||||
}
|
||||
.collection-item {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.action-bar {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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 state = {
|
||||
projects: [],
|
||||
currentProjectId: null,
|
||||
currentCards: [],
|
||||
collectionTree: [],
|
||||
isImportModalOpen: false,
|
||||
selectedCollectionKey: null,
|
||||
expandedCollectionKeys: new Set(),
|
||||
selectedItemKeys: new Set(),
|
||||
visibleCollectionItems: [],
|
||||
};
|
||||
|
||||
const elements = {
|
||||
projectForm: document.getElementById("create-project-form"),
|
||||
projectStatus: document.getElementById("project-status"),
|
||||
projectList: document.getElementById("project-list"),
|
||||
collectionStatus: document.getElementById("collection-status"),
|
||||
collectionTree: document.getElementById("collection-tree"),
|
||||
collectionItems: document.getElementById("collection-items"),
|
||||
selectedCount: document.getElementById("selected-count"),
|
||||
selectDescendantsButton: document.getElementById("select-descendants-button"),
|
||||
clearSelectionButton: document.getElementById("clear-selection-button"),
|
||||
importSelectedItemsButton: document.getElementById("import-selected-items-button"),
|
||||
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 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 renderCollectionTree() {
|
||||
if (!state.collectionTree.length) {
|
||||
elements.collectionTree.innerHTML = '<div class="empty">没有读取到 Zotero collections。</div>';
|
||||
return;
|
||||
}
|
||||
elements.collectionTree.innerHTML = "";
|
||||
for (const node of state.collectionTree) {
|
||||
elements.collectionTree.appendChild(renderCollectionNode(node));
|
||||
}
|
||||
}
|
||||
|
||||
function renderCollectionNode(node) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "tree-node";
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-row";
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "tree-toggle";
|
||||
toggle.textContent = node.children && node.children.length
|
||||
? (state.expandedCollectionKeys.has(node.collection_key) ? "−" : "+")
|
||||
: "·";
|
||||
toggle.disabled = !(node.children && node.children.length);
|
||||
toggle.addEventListener("click", () => toggleCollectionExpanded(node.collection_key));
|
||||
|
||||
const label = document.createElement("button");
|
||||
label.type = "button";
|
||||
label.className = "tree-label" + (state.selectedCollectionKey === node.collection_key ? " active" : "");
|
||||
label.innerHTML = `
|
||||
<strong>${node.name}</strong>
|
||||
<div class="meta">${node.descendant_item_count} 篇</div>
|
||||
`;
|
||||
label.addEventListener("click", () => selectCollection(node.collection_key));
|
||||
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(label);
|
||||
wrapper.appendChild(row);
|
||||
|
||||
if (node.children && node.children.length && state.expandedCollectionKeys.has(node.collection_key)) {
|
||||
const children = document.createElement("div");
|
||||
children.className = "tree-children";
|
||||
for (const child of node.children) {
|
||||
children.appendChild(renderCollectionNode(child));
|
||||
}
|
||||
wrapper.appendChild(children);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function renderCollectionItems() {
|
||||
renderSelectedCount();
|
||||
if (!state.selectedCollectionKey) {
|
||||
elements.collectionItems.innerHTML = '<div class="empty">先点击一个 collection。</div>';
|
||||
return;
|
||||
}
|
||||
if (!state.visibleCollectionItems.length) {
|
||||
elements.collectionItems.innerHTML = '<div class="empty">当前目录及子目录下没有可导入文献。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.collectionItems.innerHTML = "";
|
||||
for (const item of state.visibleCollectionItems) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "collection-item";
|
||||
row.innerHTML = `
|
||||
<div class="checkbox-row">
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
const checkbox = row.querySelector('input[type="checkbox"]');
|
||||
checkbox.addEventListener("change", (event) => {
|
||||
toggleItemSelection(item.item_key, event.target.checked);
|
||||
});
|
||||
elements.collectionItems.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSelectedCount() {
|
||||
elements.selectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`;
|
||||
}
|
||||
|
||||
function toggleItemSelection(itemKey, checked) {
|
||||
if (checked) {
|
||||
state.selectedItemKeys.add(itemKey);
|
||||
} else {
|
||||
state.selectedItemKeys.delete(itemKey);
|
||||
}
|
||||
renderSelectedCount();
|
||||
}
|
||||
|
||||
function toggleCollectionExpanded(collectionKey) {
|
||||
if (state.expandedCollectionKeys.has(collectionKey)) {
|
||||
state.expandedCollectionKeys.delete(collectionKey);
|
||||
} else {
|
||||
state.expandedCollectionKeys.add(collectionKey);
|
||||
}
|
||||
renderCollectionTree();
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const projects = await api("/api/projects");
|
||||
state.projects = projects;
|
||||
if (!state.currentProjectId && projects.length) {
|
||||
state.currentProjectId = projects[0].id;
|
||||
}
|
||||
renderProjects();
|
||||
if (state.currentProjectId) {
|
||||
await selectProject(state.currentProjectId, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProject(projectId, rerender = true) {
|
||||
state.currentProjectId = projectId;
|
||||
elements.currentProjectLabel.textContent = `当前项目:${projectId}`;
|
||||
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);
|
||||
}
|
||||
|
||||
async function loadCollectionTree() {
|
||||
const payload = await api("/api/zotero/collections/tree");
|
||||
state.collectionTree = payload.collections || [];
|
||||
if (!state.selectedCollectionKey && state.collectionTree.length) {
|
||||
state.selectedCollectionKey = state.collectionTree[0].collection_key;
|
||||
state.expandedCollectionKeys.add(state.selectedCollectionKey);
|
||||
}
|
||||
renderCollectionTree();
|
||||
if (state.selectedCollectionKey) {
|
||||
await selectCollection(state.selectedCollectionKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectCollection(collectionKey, rerenderTree = true) {
|
||||
state.selectedCollectionKey = collectionKey;
|
||||
const payload = await api(`/api/zotero/collections/${collectionKey}/items?include_descendants=true`);
|
||||
state.visibleCollectionItems = payload.items || [];
|
||||
if (rerenderTree) {
|
||||
renderCollectionTree();
|
||||
}
|
||||
renderCollectionItems();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
async function importItemKeys(itemKeys) {
|
||||
if (!state.currentProjectId) {
|
||||
setStatus(elements.collectionStatus, "请先选择项目。", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await api(`/api/projects/${state.currentProjectId}/imports/item-keys`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ item_keys: itemKeys }),
|
||||
});
|
||||
setStatus(elements.collectionStatus, `已导入 ${payload.imported_item_keys.length} 篇文献。`);
|
||||
renderCards(payload.project_view.cards || []);
|
||||
state.selectedItemKeys.clear();
|
||||
renderCollectionItems();
|
||||
} catch (error) {
|
||||
setStatus(elements.collectionStatus, error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
elements.reloadCardsButton.addEventListener("click", async () => {
|
||||
if (!state.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
await selectProject(state.currentProjectId);
|
||||
});
|
||||
|
||||
elements.refreshProjectsButton.addEventListener("click", loadProjects);
|
||||
|
||||
elements.selectDescendantsButton.addEventListener("click", () => {
|
||||
for (const item of state.visibleCollectionItems) {
|
||||
state.selectedItemKeys.add(item.item_key);
|
||||
}
|
||||
renderCollectionItems();
|
||||
});
|
||||
|
||||
elements.clearSelectionButton.addEventListener("click", () => {
|
||||
state.selectedItemKeys.clear();
|
||||
renderCollectionItems();
|
||||
});
|
||||
|
||||
elements.importSelectedItemsButton.addEventListener("click", async () => {
|
||||
await importItemKeys(Array.from(state.selectedItemKeys));
|
||||
});
|
||||
|
||||
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 }),
|
||||
});
|
||||
setStatus(elements.recommendStatus, `返回 ${payload.results.length} 条候选引用。`);
|
||||
renderRecommendationResults(payload.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 }),
|
||||
});
|
||||
setStatus(elements.planStatus, `生成 ${payload.sections.length} 个方案段落。`);
|
||||
renderPlanResults(payload.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"));
|
||||
|
||||
Promise.all([loadProjects(), loadCollectionTree()]).catch((error) => {
|
||||
setStatus(elements.projectStatus, error.message, true);
|
||||
setStatus(elements.collectionStatus, error.message, true);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user