979 lines
36 KiB
HTML
979 lines
36 KiB
HTML
<!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>
|