@musnows/scriverse 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.md +3 -1
- package/dist/app.js +1 -0
- package/dist/app.js.map +1 -1
- package/dist/database.js +17 -0
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +285 -124
- package/dist/public/character-version.js +3 -1
- package/dist/public/index.html +22 -6
- package/dist/public/keyboard-shortcuts.d.ts +9 -0
- package/dist/public/keyboard-shortcuts.js +4 -0
- package/dist/public/relationship-graph.js +62 -2
- package/dist/public/styles.css +152 -43
- package/dist/store.js +10 -7
- package/dist/store.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/public/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=
|
|
1
|
+
import { buildRelationshipGraph, createGalaxyRenderer, renderRelationshipMindMap } from "/relationship-graph.js?v=20260724-relationship-density";
|
|
2
2
|
import { collapseExcessBlankLines, formatDateTime, normalizeParagraphSpacing } from "/text-formatting.js?v=20260713-saved-at-seconds";
|
|
3
3
|
import { renderMarkdown } from "/markdown.js?v=20260722-inline-code";
|
|
4
4
|
import { buildAiReferenceScope, findAiMention, listAiMentionOptions } from "/ai-mentions.js?v=20260716-chapter-references";
|
|
@@ -21,6 +21,7 @@ import { buildRaceForest, eligibleRaceParents, racePathLabel } from "/race-hiera
|
|
|
21
21
|
import { ANALYSIS_TYPES, analysisTypeDescription } from "/analysis-types.js?v=20260721-analysis-descriptions";
|
|
22
22
|
import { WORK_PERMISSION_MODULES, canReadPermissionModule, canReadUiModule, canWritePermissionModule, canWriteUiModule, emptyModulePermissions, firstReadableUiModule, normalizeModulePermissions, permissionSummary } from "/work-permissions.js?v=20260723-ai-analysis-permission";
|
|
23
23
|
import { MODULE_LAYOUT_STORAGE_KEY, LEGACY_SETTINGS_LAYOUT_STORAGE_KEY, normalizeModuleLayout, moduleLayoutLabel } from "/module-layout.js?v=20260723-module-layout-toggle";
|
|
24
|
+
import { isGlobalSearchShortcut } from "/keyboard-shortcuts.js?v=20260723-global-search";
|
|
24
25
|
|
|
25
26
|
const state = {
|
|
26
27
|
user: null,
|
|
@@ -52,6 +53,8 @@ const state = {
|
|
|
52
53
|
contextChapterId: null
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
let timelineMultiSelectEnabled = false;
|
|
57
|
+
|
|
55
58
|
const chapterTypes = ["正文", "设定", "作者的话", "其他"];
|
|
56
59
|
|
|
57
60
|
const taskTypeLabels = MODEL_PURPOSE_OPTIONS;
|
|
@@ -623,14 +626,25 @@ function syncChapterLineNumberScroll() {
|
|
|
623
626
|
}
|
|
624
627
|
}
|
|
625
628
|
|
|
629
|
+
function syncChapterWhitespaceControls() {
|
|
630
|
+
document.querySelectorAll("[data-toggle-whitespace]").forEach((button) => {
|
|
631
|
+
button.setAttribute("aria-pressed", String(chapterWhitespaceVisible));
|
|
632
|
+
button.textContent = chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符";
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function toggleChapterWhitespaceVisibility() {
|
|
637
|
+
chapterWhitespaceVisible = !chapterWhitespaceVisible;
|
|
638
|
+
syncChapterWhitespaceControls();
|
|
639
|
+
scheduleChapterLineNumbers();
|
|
640
|
+
}
|
|
641
|
+
|
|
626
642
|
function renderChapterWhitespaceMarkers(input, style) {
|
|
627
643
|
const overlay = $("#chapter-whitespace-overlay");
|
|
628
644
|
const inner = $("#chapter-whitespace-inner");
|
|
629
|
-
|
|
630
|
-
if (!overlay || !inner
|
|
645
|
+
syncChapterWhitespaceControls();
|
|
646
|
+
if (!overlay || !inner) return;
|
|
631
647
|
overlay.classList.toggle("is-visible", chapterWhitespaceVisible);
|
|
632
|
-
button.setAttribute("aria-pressed", String(chapterWhitespaceVisible));
|
|
633
|
-
button.textContent = chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符";
|
|
634
648
|
if (!chapterWhitespaceVisible) {
|
|
635
649
|
inner.replaceChildren();
|
|
636
650
|
return;
|
|
@@ -1504,6 +1518,7 @@ function renderTypographyPreview() {
|
|
|
1504
1518
|
function openAppearanceDialog() {
|
|
1505
1519
|
fillAppearanceForm(typographySettings);
|
|
1506
1520
|
renderTypographyPreview();
|
|
1521
|
+
syncChapterWhitespaceControls();
|
|
1507
1522
|
$("#appearance-dialog").showModal();
|
|
1508
1523
|
}
|
|
1509
1524
|
|
|
@@ -2209,6 +2224,11 @@ async function openSearchDialog() {
|
|
|
2209
2224
|
toast("请先打开一部作品", "error");
|
|
2210
2225
|
return;
|
|
2211
2226
|
}
|
|
2227
|
+
if ($("#search-dialog").open) {
|
|
2228
|
+
$("#search-query").focus();
|
|
2229
|
+
$("#search-query").select();
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2212
2232
|
$("#search-dialog .eyebrow").textContent = `当前作品 · 《${state.work.title}》`;
|
|
2213
2233
|
$("#search-query").value = "";
|
|
2214
2234
|
$("#search-results").innerHTML = '<p class="search-results-empty">输入关键词后开始检索。</p>';
|
|
@@ -2312,7 +2332,7 @@ async function returnFromSettings() {
|
|
|
2312
2332
|
$("#app").classList.remove("shelf-mode");
|
|
2313
2333
|
$("#shelf-view").classList.add("hidden");
|
|
2314
2334
|
updateDocumentTitle(state.work);
|
|
2315
|
-
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
|
|
2335
|
+
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
|
|
2316
2336
|
if (context.view === "module") return showModule(context.module);
|
|
2317
2337
|
if (context.view === "editor" && context.chapterId) return selectChapter(context.chapterId);
|
|
2318
2338
|
return showWelcome(true);
|
|
@@ -2346,7 +2366,7 @@ function renderShelf() {
|
|
|
2346
2366
|
<span class="book-cover-fallback">${esc(Array.from(work.title)[0] ?? "书")}</span>
|
|
2347
2367
|
${work.coverUrl ? `<img src="${esc(work.coverUrl)}" alt="${esc(work.title)} 封面">` : ""}
|
|
2348
2368
|
</span>
|
|
2349
|
-
<span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${work.wordCount} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "全部只读" : work.accessRole === "settings-editor" ? "设定协作" : work.accessRole === "editor" ? "全部可编辑" : work.accessRole === "custom" ? "自定义权限" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
|
|
2369
|
+
<span class="book-info"><strong>${esc(work.title)}</strong><small>${esc(work.author || "未署名")} · ${work.chapterCount} 章 · ${Number(work.wordCount ?? 0).toLocaleString("zh-CN")} 字</small><span>${esc(work.description || "尚未填写作品简介")}</span><em class="book-access-badge">${work.accessRole === "viewer" ? "全部只读" : work.accessRole === "settings-editor" ? "设定协作" : work.accessRole === "editor" ? "全部可编辑" : work.accessRole === "custom" ? "自定义权限" : work.accessRole === "admin" ? "管理员访问" : "我的作品"}</em></span>
|
|
2350
2370
|
</button>
|
|
2351
2371
|
${canManageWork(work) ? `<button class="book-card-settings" type="button" data-edit-work="${esc(work.id)}" aria-label="作品设置" title="作品设置">设置</button>` : ""}
|
|
2352
2372
|
</article>`).join("")}
|
|
@@ -2410,7 +2430,7 @@ async function selectWork(workId, preferredChapterId = null) {
|
|
|
2410
2430
|
if (!canReadModule(state.module)) state.module = firstReadableUiModule(state.work) ?? "editor";
|
|
2411
2431
|
applyWorkAccessMode();
|
|
2412
2432
|
updateDocumentTitle(state.work);
|
|
2413
|
-
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
|
|
2433
|
+
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
|
|
2414
2434
|
$("#top-search-button").disabled = !canReadAggregateContent();
|
|
2415
2435
|
renderTree();
|
|
2416
2436
|
const chapters = state.work.volumes.flatMap((volume) => volume.chapters);
|
|
@@ -2599,6 +2619,7 @@ async function showModule(module) {
|
|
|
2599
2619
|
$("#module-eyebrow").textContent = meta[0];
|
|
2600
2620
|
$("#module-title").textContent = meta[1];
|
|
2601
2621
|
$("#module-description").textContent = meta[2];
|
|
2622
|
+
$("#module-header-actions").querySelectorAll("[data-module-header-action]").forEach((action) => action.remove());
|
|
2602
2623
|
$("#module-create-button").textContent = meta[3];
|
|
2603
2624
|
$("#module-create-button").classList.toggle("hidden", module === "ai-settings" || !canEditModule(module));
|
|
2604
2625
|
$("#module-content").innerHTML = '<div class="empty-state">正在载入……</div>';
|
|
@@ -2689,30 +2710,82 @@ function bindEntityHistoryButtons(refresh) {
|
|
|
2689
2710
|
}));
|
|
2690
2711
|
}
|
|
2691
2712
|
|
|
2713
|
+
function pencilIconMarkup() {
|
|
2714
|
+
return '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20h9"></path><path d="m16.5 3.5 1.4-1.4a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4L16.5 3.5Z"></path></svg>';
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
function recordCardEditButton(attribute, id, label) {
|
|
2718
|
+
return `<button class="record-card-edit" type="button" data-${attribute}="${esc(id)}" aria-label="编辑${esc(label)}" title="编辑">${pencilIconMarkup()}</button>`;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
function recordHistoryButton(type, id, title) {
|
|
2722
|
+
return `<button type="button" data-entity-history="${esc(type)}" data-entity-id="${esc(id)}" data-entity-title="${esc(title)}">版本历史</button>`;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
function entityDialogManagementHtml({ typeLabel, canMerge, canDelete }) {
|
|
2726
|
+
return `<section class="entity-dialog-management" aria-label="${esc(typeLabel)}档案操作">
|
|
2727
|
+
<div><strong>档案操作</strong><small>版本历史和高风险操作集中在编辑面板内。</small></div>
|
|
2728
|
+
<div class="entity-dialog-management-actions">
|
|
2729
|
+
<button class="ghost-button" type="button" data-dialog-entity-history>版本历史</button>
|
|
2730
|
+
${canMerge ? `<button class="ghost-button" type="button" data-dialog-entity-merge>合并</button>` : ""}
|
|
2731
|
+
${canDelete ? `<button class="danger-button" type="button" data-dialog-entity-delete>删除</button>` : ""}
|
|
2732
|
+
</div>
|
|
2733
|
+
</section>`;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
function bindManagedEntityDialogActions({ type, typeLabel, item, candidates, endpoint, body, impact, refresh, deleteEndpoint, warning }) {
|
|
2737
|
+
if (!item) return;
|
|
2738
|
+
const fields = $("#dialog-fields");
|
|
2739
|
+
fields.querySelector("[data-dialog-entity-history]")?.addEventListener("click", () => {
|
|
2740
|
+
$("#form-dialog").close();
|
|
2741
|
+
openEntityHistory(type, item.id, item.name, async () => { await refresh(); await loadAiReferences(); });
|
|
2742
|
+
});
|
|
2743
|
+
fields.querySelector("[data-dialog-entity-merge]")?.addEventListener("click", () => {
|
|
2744
|
+
$("#form-dialog").close();
|
|
2745
|
+
openEntityMergeDialog({ typeLabel, source: item, candidates, endpoint, body, refresh, impact });
|
|
2746
|
+
});
|
|
2747
|
+
fields.querySelector("[data-dialog-entity-delete]")?.addEventListener("click", () => {
|
|
2748
|
+
void deleteManagedEntity({
|
|
2749
|
+
typeLabel,
|
|
2750
|
+
item,
|
|
2751
|
+
endpoint: deleteEndpoint,
|
|
2752
|
+
refresh,
|
|
2753
|
+
warning,
|
|
2754
|
+
onDeleted: () => $("#form-dialog").close()
|
|
2755
|
+
});
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2692
2759
|
function openEntityMergeDialog({ typeLabel, source, candidates, endpoint, body, refresh, impact }) {
|
|
2693
2760
|
const targetOptions = candidates
|
|
2694
2761
|
.filter((candidate) => candidate.id !== source.id)
|
|
2695
2762
|
.map((candidate) => [candidate.id, candidate.name]);
|
|
2696
2763
|
openDialog(`合并${typeLabel}`,
|
|
2697
2764
|
`<p class="merge-dialog-note">“${esc(source.name)}”将合并到所选档案,目标档案会保留。${esc(impact)}</p>` +
|
|
2698
|
-
field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions)
|
|
2765
|
+
field("targetId", `目标${typeLabel}`, "select", targetOptions[0]?.[0] ?? "", targetOptions) +
|
|
2766
|
+
`<label class="danger-confirm-field"><input name="mergeConfirm" type="checkbox" value="confirmed"><span><strong>我确认执行合并</strong><small>来源档案及关联资料将迁移到目标档案,合并后不可撤销。</small></span></label>`,
|
|
2699
2767
|
async (form) => {
|
|
2700
2768
|
const target = candidates.find((candidate) => candidate.id === form.get("targetId"));
|
|
2701
2769
|
if (!target) throw new Error(`请选择目标${typeLabel}`);
|
|
2770
|
+
if (form.get("mergeConfirm") !== "confirmed") throw new Error("请先完成合并二次确认");
|
|
2702
2771
|
await api(endpoint(source), { method: "POST", body: body(target) });
|
|
2703
2772
|
await refresh();
|
|
2704
2773
|
await loadAiReferences();
|
|
2705
2774
|
toast(`已将“${source.name}”合并到“${target.name}”`);
|
|
2706
2775
|
}, "人工资料管理", { submitLabel: "确认合并" });
|
|
2776
|
+
const confirmation = $("#dialog-fields").querySelector("[name='mergeConfirm']");
|
|
2777
|
+
const submit = $("#dialog-submit");
|
|
2778
|
+
submit.disabled = true;
|
|
2779
|
+
confirmation?.addEventListener("change", () => { submit.disabled = !confirmation.checked; });
|
|
2707
2780
|
}
|
|
2708
2781
|
|
|
2709
|
-
async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "" }) {
|
|
2710
|
-
const
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
if (!(await confirmToast(message, { title: `删除${typeLabel}`, confirmLabel: "确认删除" }))) return;
|
|
2782
|
+
async function deleteManagedEntity({ typeLabel, item, endpoint, refresh, warning = "", onDeleted }) {
|
|
2783
|
+
const detail = warning ? `\n${warning}` : "";
|
|
2784
|
+
if (!await confirmToast(`确认删除${typeLabel}“${item.name}”吗?${detail}`, { title: `删除${typeLabel}`, confirmLabel: "继续删除" })) return;
|
|
2785
|
+
if (!await confirmToast(`删除${typeLabel}“${item.name}”后无法恢复。${detail}`, { title: "删除操作需要再次确认", confirmLabel: "确认删除" })) return;
|
|
2714
2786
|
try {
|
|
2715
2787
|
await api(endpoint(item), { method: "DELETE" });
|
|
2788
|
+
await onDeleted?.();
|
|
2716
2789
|
await refresh();
|
|
2717
2790
|
await loadAiReferences();
|
|
2718
2791
|
toast(`已删除${typeLabel}“${item.name}”`);
|
|
@@ -2746,7 +2819,7 @@ function moduleRowPreview(text, max = 180) {
|
|
|
2746
2819
|
}
|
|
2747
2820
|
|
|
2748
2821
|
function renderModuleLayoutToggle(layout, ariaLabel = "列表样式") {
|
|
2749
|
-
return `<div class="module-layout-toolbar">
|
|
2822
|
+
return `<div class="module-layout-toolbar" data-module-header-action="layout-toggle">
|
|
2750
2823
|
<div class="module-layout-toggle" role="group" aria-label="${esc(ariaLabel)}">
|
|
2751
2824
|
<button type="button" data-module-layout="cards" aria-pressed="${layout === "cards"}">卡片</button>
|
|
2752
2825
|
<button type="button" data-module-layout="rows" aria-pressed="${layout === "rows"}">列表</button>
|
|
@@ -2755,15 +2828,22 @@ function renderModuleLayoutToggle(layout, ariaLabel = "列表样式") {
|
|
|
2755
2828
|
</div>`;
|
|
2756
2829
|
}
|
|
2757
2830
|
|
|
2831
|
+
function mountModuleLayoutToggle(layout, ariaLabel) {
|
|
2832
|
+
$("#module-header-actions").querySelector('[data-module-header-action="layout-toggle"]')?.remove();
|
|
2833
|
+
$("#module-header-actions").insertAdjacentHTML("beforeend", renderModuleLayoutToggle(layout, ariaLabel));
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2758
2836
|
function bindModuleLayoutToggle(refresh) {
|
|
2759
|
-
$("#module-
|
|
2837
|
+
$("#module-header-actions").querySelectorAll("[data-module-layout]").forEach((button) => button.addEventListener("click", async () => {
|
|
2760
2838
|
saveModuleLayout(button.dataset.moduleLayout);
|
|
2761
2839
|
await refresh();
|
|
2762
2840
|
}));
|
|
2763
2841
|
}
|
|
2764
2842
|
|
|
2765
2843
|
function settingRecordActions(item) {
|
|
2766
|
-
return
|
|
2844
|
+
return canEditModule("settings")
|
|
2845
|
+
? recordCardEditButton("edit-setting", item.id, `设定“${item.title}”`)
|
|
2846
|
+
: recordHistoryButton("setting", item.id, item.title);
|
|
2767
2847
|
}
|
|
2768
2848
|
|
|
2769
2849
|
function renderSettingCards(records) {
|
|
@@ -2787,15 +2867,11 @@ async function renderSettings() {
|
|
|
2787
2867
|
const records = (await apiPage(`/api/works/${state.work.id}/settings`)).items;
|
|
2788
2868
|
state.settings = records;
|
|
2789
2869
|
const layout = readModuleLayout();
|
|
2870
|
+
if (records.length) mountModuleLayoutToggle(layout, "设定列表样式");
|
|
2790
2871
|
$("#module-content").innerHTML = records.length
|
|
2791
|
-
? `${
|
|
2872
|
+
? `${layout === "rows" ? renderSettingRows(records) : renderSettingCards(records)}`
|
|
2792
2873
|
: emptyModule("还没有世界观设定", "新建规则、地点、组织、科技或创作约束。AI 提取的候选也会进入这里。");
|
|
2793
2874
|
bindModuleLayoutToggle(renderSettings);
|
|
2794
|
-
$("#module-content").querySelectorAll("[data-setting-status]").forEach((button) => button.addEventListener("click", async () => {
|
|
2795
|
-
await api(`/api/settings/${button.dataset.settingId}`, { method: "PATCH", body: { status: button.dataset.settingStatus, changeNote: button.dataset.settingStatus === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
|
|
2796
|
-
await renderSettings();
|
|
2797
|
-
await loadAiReferences();
|
|
2798
|
-
}));
|
|
2799
2875
|
$("#module-content").querySelectorAll("[data-edit-setting]").forEach((button) => button.addEventListener("click", () => openSettingEditor(records.find((item) => item.id === button.dataset.editSetting))));
|
|
2800
2876
|
bindEntityHistoryButtons(async () => { await renderSettings(); await loadAiReferences(); });
|
|
2801
2877
|
}
|
|
@@ -2807,23 +2883,26 @@ async function renderCharacters() {
|
|
|
2807
2883
|
canReadModule("organizations") ? apiAllPages(`/api/works/${state.work.id}/organizations`) : Promise.resolve([])
|
|
2808
2884
|
]);
|
|
2809
2885
|
const layout = readModuleLayout();
|
|
2810
|
-
const characterActions = (item) =>
|
|
2886
|
+
const characterActions = (item) => recordCardEditButton("edit-character", item.id, `角色“${item.name}”`);
|
|
2811
2887
|
const characterCards = () => `<div class="card-grid">${state.characters.map((item) => {
|
|
2812
2888
|
const details = normalizeCharacterDetails(item.attributes?.details);
|
|
2813
2889
|
return `
|
|
2814
|
-
<article class="record-card character-card" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}"
|
|
2815
|
-
<h3>${esc(item.name)}</h3
|
|
2816
|
-
${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
|
|
2890
|
+
<article class="record-card character-card has-card-edit" data-open-character="${esc(item.id)}" role="button" tabindex="0" aria-label="查看角色 ${esc(item.name)}">${recordCardEditButton("edit-character", item.id, `角色“${item.name}”`)}<small>${item.lockedFields.length ? `锁定 ${item.lockedFields.length} 项` : esc(item.visibility)}</small>
|
|
2891
|
+
<h3>${esc(item.name)}</h3>
|
|
2817
2892
|
${item.attributes?.identity ? `<p class="character-identity">${esc(item.attributes.identity)}</p>` : ""}
|
|
2893
|
+
${item.aliases.length ? `<div class="character-aliases">${item.aliases.map((alias) => `<span class="pill">${esc(alias)}</span>`).join("")}</div>` : ""}
|
|
2894
|
+
${item.code ? `<div class="character-code"><b>编号</b><span class="pill">${esc(item.code)}</span></div>` : ""}
|
|
2895
|
+
${item.species ? `<div class="character-species"><b>种族</b><span class="pill">${esc(racePathLabel(item.race) || item.species)}</span></div>` : ""}
|
|
2818
2896
|
${details.length ? `<dl class="character-detail-list">${details.slice(0, 4).map((detail) => `<div><dt>${esc(detail.label)}</dt><dd>${esc(detail.value)}</dd></div>`).join("")}</dl>` : ""}
|
|
2819
2897
|
<div class="organization-links"><b>所属组织</b>${(item.organizations ?? []).length ? item.organizations.map((organization) => `<span class="pill organization-pill">${esc(organization.name)}</span>`).join("") : '<span class="organization-empty">未加入组织</span>'}</div>
|
|
2820
2898
|
${item.profile?.summary ? `<p class="character-summary">${esc(item.profile.summary)}</p>` : `<p>${esc(Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join("\n") || "尚未记录当前状态")}</p>`}
|
|
2821
2899
|
${item.profileSectionCount ? `<small class="character-section-count">${item.profileSectionCount} 个设定章节</small>` : ""}
|
|
2822
|
-
|
|
2900
|
+
</article>`;
|
|
2823
2901
|
}).join("")}</div>`;
|
|
2824
2902
|
const characterRows = () => `<div class="module-row-list">${state.characters.map((item) => {
|
|
2825
2903
|
const preview = moduleRowPreview(item.profile?.summary || item.attributes?.identity || Object.entries(item.currentState).map(([key, value]) => `${key}:${value}`).join(" ") || "尚未记录当前状态");
|
|
2826
2904
|
const meta = [
|
|
2905
|
+
item.code ? `编号 ${item.code}` : "",
|
|
2827
2906
|
item.species ? (racePathLabel(item.race) || item.species) : "",
|
|
2828
2907
|
...(item.aliases ?? []).slice(0, 3),
|
|
2829
2908
|
(item.organizations ?? []).length ? (item.organizations ?? []).map((organization) => organization.name).join("、") : ""
|
|
@@ -2838,8 +2917,9 @@ async function renderCharacters() {
|
|
|
2838
2917
|
</article>`;
|
|
2839
2918
|
}).join("")}</div>`;
|
|
2840
2919
|
const auditPanel = canEditModule("tasks") ? `<section class="character-audit-panel"><div><strong>角色身份确认</strong><small>让 AI 查询角色档案并搜索正文,找出可能被误建成两个档案的同一角色。AI 只提交审核建议,不会自动合并。</small></div><button id="create-character-audit-task" class="ghost-button" type="button" ${state.characters.length < 2 ? "disabled" : ""}>AI 角色查重</button></section>` : "";
|
|
2920
|
+
if (state.characters.length) mountModuleLayoutToggle(layout, "角色列表样式");
|
|
2841
2921
|
$("#module-content").innerHTML = auditPanel + (state.characters.length
|
|
2842
|
-
? `${
|
|
2922
|
+
? `${layout === "rows" ? characterRows() : characterCards()}`
|
|
2843
2923
|
: emptyModule("还没有角色档案", "创建主要人物,并维护别名、身份、动机和当前状态。"));
|
|
2844
2924
|
bindModuleLayoutToggle(renderCharacters);
|
|
2845
2925
|
$("#create-character-audit-task")?.addEventListener("click", async () => {
|
|
@@ -2864,34 +2944,6 @@ async function renderCharacters() {
|
|
|
2864
2944
|
});
|
|
2865
2945
|
});
|
|
2866
2946
|
$("#module-content").querySelectorAll("[data-edit-character]").forEach((button) => button.addEventListener("click", () => openCharacterEditor(state.characters.find((item) => item.id === button.dataset.editCharacter))));
|
|
2867
|
-
$("#module-content").querySelectorAll("[data-merge-character]").forEach((button) => button.addEventListener("click", () => {
|
|
2868
|
-
const source = state.characters.find((item) => item.id === button.dataset.mergeCharacter);
|
|
2869
|
-
if (!source) return;
|
|
2870
|
-
openEntityMergeDialog({
|
|
2871
|
-
typeLabel: "角色",
|
|
2872
|
-
source,
|
|
2873
|
-
candidates: state.characters,
|
|
2874
|
-
endpoint: (item) => `/api/characters/${encodeURIComponent(item.id)}/merge`,
|
|
2875
|
-
body: (target) => ({
|
|
2876
|
-
targetCharacterId: target.id,
|
|
2877
|
-
expectedTargetVersionNo: target.versionNo,
|
|
2878
|
-
expectedSourceVersionNo: source.versionNo
|
|
2879
|
-
}),
|
|
2880
|
-
refresh: renderCharacters,
|
|
2881
|
-
impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
|
|
2882
|
-
});
|
|
2883
|
-
}));
|
|
2884
|
-
$("#module-content").querySelectorAll("[data-delete-character]").forEach((button) => button.addEventListener("click", () => {
|
|
2885
|
-
const item = state.characters.find((character) => character.id === button.dataset.deleteCharacter);
|
|
2886
|
-
if (!item) return;
|
|
2887
|
-
void deleteManagedEntity({
|
|
2888
|
-
typeLabel: "角色",
|
|
2889
|
-
item,
|
|
2890
|
-
endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
|
|
2891
|
-
refresh: renderCharacters,
|
|
2892
|
-
warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
|
|
2893
|
-
});
|
|
2894
|
-
}));
|
|
2895
2947
|
}
|
|
2896
2948
|
|
|
2897
2949
|
async function renderRaces() {
|
|
@@ -2900,16 +2952,22 @@ async function renderRaces() {
|
|
|
2900
2952
|
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
2901
2953
|
]);
|
|
2902
2954
|
const layout = readModuleLayout();
|
|
2903
|
-
const
|
|
2955
|
+
const canEditRaces = canEditModule("races");
|
|
2956
|
+
const raceActions = (item) => canEditRaces
|
|
2957
|
+
? recordCardEditButton("edit-race", item.id, `种族“${item.name}”`)
|
|
2958
|
+
: recordHistoryButton("race", item.id, item.name);
|
|
2959
|
+
const raceCardActions = (item) => canEditRaces
|
|
2960
|
+
? raceActions(item)
|
|
2961
|
+
: `<div class="card-actions">${raceActions(item)}</div>`;
|
|
2904
2962
|
const renderRaceNode = (item) => `<details class="race-tree-node" open data-race-node="${esc(item.id)}">
|
|
2905
2963
|
<summary><span>${esc(item.name)}</span><small>${item.children.length} 个直接子种族</small></summary>
|
|
2906
2964
|
<div class="race-tree-branch">
|
|
2907
|
-
<article class="record-card race-card"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length
|
|
2965
|
+
<article class="record-card race-card${canEditRaces ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位直接角色 · ${item.settings.length} 条自身设定</small>
|
|
2908
2966
|
<div class="race-path" aria-label="种族路径">${esc(racePathLabel(item))}</div>
|
|
2909
2967
|
<p>${esc(item.description || "尚未填写种族简介")}</p>
|
|
2910
2968
|
<div class="race-settings">${item.effectiveSettings.length ? item.effectiveSettings.map((setting) => `<section class="knowledge-markdown-block${setting.inherited ? " inherited" : ""}"><div class="knowledge-markdown-block-heading"><h4>${esc(setting.title || "未命名章节")}</h4><small>${esc(setting.inherited ? `继承自 ${setting.sourceRaceName}` : `定义于 ${setting.sourceRaceName}`)}</small></div><div class="message-body">${renderMarkdown(setting.value) || '<p class="markdown-editor-empty">暂无内容</p>'}</div></section>`).join("") : '<span class="pill">暂无共同设定</span>'}</div>
|
|
2911
2969
|
<p class="race-members">直接角色:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
|
|
2912
|
-
|
|
2970
|
+
${raceCardActions(item)}
|
|
2913
2971
|
</article>
|
|
2914
2972
|
${item.children.length ? `<div class="race-tree-children">${item.children.map(renderRaceNode).join("")}</div>` : ""}
|
|
2915
2973
|
</div>
|
|
@@ -2925,35 +2983,12 @@ async function renderRaces() {
|
|
|
2925
2983
|
<div class="card-actions">${raceActions(item)}</div>
|
|
2926
2984
|
</article>`;
|
|
2927
2985
|
}).join("")}</div>`;
|
|
2986
|
+
if (state.races.length) mountModuleLayoutToggle(layout, "种族列表样式");
|
|
2928
2987
|
$("#module-content").innerHTML = state.races.length
|
|
2929
|
-
? `${
|
|
2988
|
+
? `${layout === "rows" ? raceRows() : `<section class="race-tree" aria-label="种族层级">${buildRaceForest(state.races).map(renderRaceNode).join("")}</section>`}`
|
|
2930
2989
|
: emptyModule("还没有种族档案", "先创建种族及共同设定,之后角色编辑器才能选择该种族。");
|
|
2931
2990
|
bindModuleLayoutToggle(renderRaces);
|
|
2932
2991
|
$("#module-content").querySelectorAll("[data-edit-race]").forEach((button) => button.addEventListener("click", () => openRaceDialog(state.races.find((item) => item.id === button.dataset.editRace))));
|
|
2933
|
-
$("#module-content").querySelectorAll("[data-merge-race]").forEach((button) => button.addEventListener("click", () => {
|
|
2934
|
-
const source = state.races.find((item) => item.id === button.dataset.mergeRace);
|
|
2935
|
-
if (!source) return;
|
|
2936
|
-
openEntityMergeDialog({
|
|
2937
|
-
typeLabel: "种族",
|
|
2938
|
-
source,
|
|
2939
|
-
candidates: state.races,
|
|
2940
|
-
endpoint: (item) => `/api/races/${encodeURIComponent(item.id)}/merge`,
|
|
2941
|
-
body: (target) => ({ targetRaceId: target.id }),
|
|
2942
|
-
refresh: renderRaces,
|
|
2943
|
-
impact: "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。"
|
|
2944
|
-
});
|
|
2945
|
-
}));
|
|
2946
|
-
$("#module-content").querySelectorAll("[data-delete-race]").forEach((button) => button.addEventListener("click", () => {
|
|
2947
|
-
const item = state.races.find((race) => race.id === button.dataset.deleteRace);
|
|
2948
|
-
if (!item) return;
|
|
2949
|
-
void deleteManagedEntity({
|
|
2950
|
-
typeLabel: "种族",
|
|
2951
|
-
item,
|
|
2952
|
-
endpoint: (race) => `/api/races/${encodeURIComponent(race.id)}`,
|
|
2953
|
-
refresh: renderRaces,
|
|
2954
|
-
warning: "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。"
|
|
2955
|
-
});
|
|
2956
|
-
}));
|
|
2957
2992
|
bindEntityHistoryButtons(async () => { await renderRaces(); await loadAiReferences(); });
|
|
2958
2993
|
}
|
|
2959
2994
|
|
|
@@ -2963,13 +2998,19 @@ async function renderOrganizations() {
|
|
|
2963
2998
|
canReadModule("characters") ? apiAllPages(`/api/works/${state.work.id}/characters`) : Promise.resolve([])
|
|
2964
2999
|
]);
|
|
2965
3000
|
const layout = readModuleLayout();
|
|
2966
|
-
const
|
|
3001
|
+
const canEditOrganizations = canEditModule("organizations");
|
|
3002
|
+
const organizationActions = (item) => canEditOrganizations
|
|
3003
|
+
? recordCardEditButton("edit-organization", item.id, `组织“${item.name}”`)
|
|
3004
|
+
: recordHistoryButton("organization", item.id, item.name);
|
|
3005
|
+
const organizationCardActions = (item) => canEditOrganizations
|
|
3006
|
+
? organizationActions(item)
|
|
3007
|
+
: `<div class="card-actions">${organizationActions(item)}</div>`;
|
|
2967
3008
|
const organizationCards = () => `<div class="card-grid organization-grid">${state.organizations.map((item) => `
|
|
2968
|
-
<article class="record-card organization-card"><small>${item.memberIds.length} 位成员 · ${item.settings.length ? "已填写组织设定" : "暂无组织设定"}</small>
|
|
3009
|
+
<article class="record-card organization-card${canEditOrganizations ? " has-card-edit" : ""}"><small>${item.memberIds.length} 位成员 · ${item.settings.length ? "已填写组织设定" : "暂无组织设定"}</small>
|
|
2969
3010
|
<h3>${esc(item.name)}</h3><p>${esc(item.description || "尚未填写组织简介")}</p>
|
|
2970
3011
|
<div class="organization-settings">${item.settingsSections?.length ? item.settingsSections.map((section) => `<article class="knowledge-markdown-block"><div class="knowledge-markdown-block-heading"><h4>${esc(section.title || "未命名章节")}</h4></div><div class="message-body">${renderMarkdown(section.contentMarkdown) || '<p class="markdown-editor-empty">暂无内容</p>'}</div></article>`).join("") : '<span class="pill">暂无组织设定</span>'}</div>
|
|
2971
3012
|
<p class="organization-members">成员:${item.members.length ? item.members.map((member) => esc(member.name)).join("、") : "暂无绑定角色"}</p>
|
|
2972
|
-
|
|
3013
|
+
${organizationCardActions(item)}
|
|
2973
3014
|
</article>`).join("")}</div>`;
|
|
2974
3015
|
const organizationRows = () => `<div class="module-row-list">${state.organizations.map((item) => {
|
|
2975
3016
|
const preview = moduleRowPreview(item.description || "尚未填写组织简介");
|
|
@@ -2982,51 +3023,56 @@ async function renderOrganizations() {
|
|
|
2982
3023
|
<div class="card-actions">${organizationActions(item)}</div>
|
|
2983
3024
|
</article>`;
|
|
2984
3025
|
}).join("")}</div>`;
|
|
3026
|
+
if (state.organizations.length) mountModuleLayoutToggle(layout, "组织列表样式");
|
|
2985
3027
|
$("#module-content").innerHTML = state.organizations.length
|
|
2986
|
-
? `${
|
|
3028
|
+
? `${layout === "rows" ? organizationRows() : organizationCards()}`
|
|
2987
3029
|
: emptyModule("还没有组织", "创建国家、机构、阵营或团队,并维护组织设定与成员。");
|
|
2988
3030
|
bindModuleLayoutToggle(renderOrganizations);
|
|
2989
3031
|
$("#module-content").querySelectorAll("[data-edit-organization]").forEach((button) => button.addEventListener("click", () => openOrganizationDialog(state.organizations.find((item) => item.id === button.dataset.editOrganization))));
|
|
2990
|
-
$("#module-content").querySelectorAll("[data-merge-organization]").forEach((button) => button.addEventListener("click", () => {
|
|
2991
|
-
const source = state.organizations.find((item) => item.id === button.dataset.mergeOrganization);
|
|
2992
|
-
if (!source) return;
|
|
2993
|
-
openEntityMergeDialog({
|
|
2994
|
-
typeLabel: "组织",
|
|
2995
|
-
source,
|
|
2996
|
-
candidates: state.organizations,
|
|
2997
|
-
endpoint: (item) => `/api/organizations/${encodeURIComponent(item.id)}/merge`,
|
|
2998
|
-
body: (target) => ({ targetOrganizationId: target.id }),
|
|
2999
|
-
refresh: renderOrganizations,
|
|
3000
|
-
impact: "来源组织的成员、简介与组织设定会迁移到目标组织。"
|
|
3001
|
-
});
|
|
3002
|
-
}));
|
|
3003
|
-
$("#module-content").querySelectorAll("[data-delete-organization]").forEach((button) => button.addEventListener("click", () => {
|
|
3004
|
-
const item = state.organizations.find((organization) => organization.id === button.dataset.deleteOrganization);
|
|
3005
|
-
if (!item) return;
|
|
3006
|
-
void deleteManagedEntity({
|
|
3007
|
-
typeLabel: "组织",
|
|
3008
|
-
item,
|
|
3009
|
-
endpoint: (organization) => `/api/organizations/${encodeURIComponent(organization.id)}`,
|
|
3010
|
-
refresh: renderOrganizations,
|
|
3011
|
-
warning: "角色与该组织的成员关系会一并移除。"
|
|
3012
|
-
});
|
|
3013
|
-
}));
|
|
3014
3032
|
bindEntityHistoryButtons(async () => { await renderOrganizations(); await loadAiReferences(); });
|
|
3015
3033
|
}
|
|
3016
3034
|
|
|
3035
|
+
function updateTimelineMultiSelectControls() {
|
|
3036
|
+
const toggle = $("#timeline-multi-select-toggle");
|
|
3037
|
+
if (!toggle) return;
|
|
3038
|
+
const selectedCount = $("#module-content").querySelectorAll("[data-event-select]:checked").length;
|
|
3039
|
+
toggle.setAttribute("aria-pressed", String(timelineMultiSelectEnabled));
|
|
3040
|
+
toggle.textContent = timelineMultiSelectEnabled ? "退出多选" : "多选";
|
|
3041
|
+
$("#module-content").querySelectorAll("[data-event-select]").forEach((input) => {
|
|
3042
|
+
input.hidden = !timelineMultiSelectEnabled;
|
|
3043
|
+
if (!timelineMultiSelectEnabled) input.checked = false;
|
|
3044
|
+
});
|
|
3045
|
+
const merge = $("#merge-events");
|
|
3046
|
+
if (merge) {
|
|
3047
|
+
merge.hidden = !timelineMultiSelectEnabled;
|
|
3048
|
+
merge.disabled = !timelineMultiSelectEnabled || selectedCount < 2;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
function setTimelineMultiSelectMode(enabled) {
|
|
3053
|
+
timelineMultiSelectEnabled = enabled;
|
|
3054
|
+
updateTimelineMultiSelectControls();
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3017
3057
|
async function renderTimeline() {
|
|
3018
3058
|
const [events, tracks] = await Promise.all([
|
|
3019
3059
|
apiPage(`/api/works/${state.work.id}/timeline`).then((result) => result.items),
|
|
3020
3060
|
apiAllPages(`/api/works/${state.work.id}/timeline-tracks`)
|
|
3021
3061
|
]);
|
|
3062
|
+
timelineMultiSelectEnabled = false;
|
|
3063
|
+
$("#timeline-tools")?.remove();
|
|
3064
|
+
$("#module-header-actions").insertAdjacentHTML("beforeend", `<div id="timeline-tools" class="timeline-tools" data-module-header-action="timeline-tools" role="group" aria-label="时间轴操作"><button id="create-timeline-track" class="ghost-button" type="button">新建独立时间轴</button><button id="timeline-multi-select-toggle" class="ghost-button" type="button" aria-pressed="false">多选</button>${events.length > 1 ? '<button id="merge-events" class="ghost-button" type="button" hidden>合并所选事件</button>' : ""}</div>`);
|
|
3022
3065
|
state.timelineTracks = tracks;
|
|
3023
3066
|
const lanes = [...tracks, { id: "", name: "未分组时间轴", description: "尚未归入独立大事件的时间节点。", sortOrder: Number.MAX_SAFE_INTEGER }];
|
|
3024
|
-
const eventCard = (item) => `<article class="timeline-kanban-card"><div class="timeline-card-meta"><input type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}"><small>${esc(item.timeLabel)} · ${esc(item.status)}</small></div><h4>${esc(item.name)}</h4><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span>地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}">编辑与排序</button><button data-split-event="${esc(item.id)}">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div></article>`;
|
|
3025
|
-
$("#module-content").innerHTML = `<div class="timeline-
|
|
3067
|
+
const eventCard = (item) => `<article class="timeline-kanban-card"><div class="timeline-card-meta"><input type="checkbox" data-event-select="${esc(item.id)}" aria-label="选择 ${esc(item.name)}" hidden><small>${esc(item.timeLabel)} · ${esc(item.status)}</small></div><h4>${esc(item.name)}</h4><p>${esc(item.description || "暂无说明")}</p>${item.location ? `<span>地点:${esc(item.location)}</span>` : ""}<div class="card-actions"><button data-edit-event="${esc(item.id)}">编辑与排序</button><button data-split-event="${esc(item.id)}">拆分</button><button data-entity-history="timeline-event" data-entity-id="${esc(item.id)}" data-entity-title="${esc(item.name)}">版本历史</button></div></article>`;
|
|
3068
|
+
$("#module-content").innerHTML = `<div class="timeline-kanban" data-testid="timeline-kanban">${lanes.map((track) => {
|
|
3026
3069
|
const laneEvents = events.filter((item) => (item.trackId ?? "") === track.id);
|
|
3027
3070
|
return `<section class="timeline-lane" data-track-id="${esc(track.id)}"><header><div><small>${laneEvents.length} 个节点</small><h3>${esc(track.name)}</h3></div>${track.id ? `<div class="timeline-track-actions"><button class="timeline-track-menu" data-edit-timeline-track="${esc(track.id)}" type="button">编辑</button><button class="timeline-track-menu" data-entity-history="timeline-track" data-entity-id="${esc(track.id)}" data-entity-title="${esc(track.name)}" type="button">历史</button></div>` : ""}</header><p class="timeline-track-description">${esc(track.description || "暂无说明")}</p><div class="timeline-lane-events">${laneEvents.map(eventCard).join("") || '<div class="timeline-lane-empty">还没有时间节点</div>'}</div><button class="timeline-add-event" data-add-event-track="${esc(track.id)}" type="button">添加事件</button></section>`;
|
|
3028
3071
|
}).join("")}</div>`;
|
|
3029
3072
|
$("#create-timeline-track").addEventListener("click", () => openTimelineTrackDialog());
|
|
3073
|
+
$("#timeline-multi-select-toggle").addEventListener("click", () => setTimelineMultiSelectMode(!timelineMultiSelectEnabled));
|
|
3074
|
+
$("#module-content").querySelectorAll("[data-event-select]").forEach((input) => input.addEventListener("change", updateTimelineMultiSelectControls));
|
|
3075
|
+
setTimelineMultiSelectMode(false);
|
|
3030
3076
|
$("#module-content").querySelectorAll("[data-edit-timeline-track]").forEach((button) => button.addEventListener("click", () => openTimelineTrackDialog(tracks.find((track) => track.id === button.dataset.editTimelineTrack))));
|
|
3031
3077
|
$("#module-content").querySelectorAll("[data-add-event-track]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(null, button.dataset.addEventTrack || null)));
|
|
3032
3078
|
$("#module-content").querySelectorAll("[data-edit-event]").forEach((button) => button.addEventListener("click", () => openTimelineDialog(events.find((item) => item.id === button.dataset.editEvent))));
|
|
@@ -3080,8 +3126,9 @@ async function renderOutlines() {
|
|
|
3080
3126
|
</article>`;
|
|
3081
3127
|
}).join("")}</div>`;
|
|
3082
3128
|
const foreshadowHtml = foreshadows.length
|
|
3083
|
-
? `${
|
|
3129
|
+
? `${layout === "rows" ? foreshadowRows() : foreshadowCards()}`
|
|
3084
3130
|
: emptyModule("还没有伏笔", "创建伏笔并关联埋设、提醒与回收章节,未回收项会持续显示。\n");
|
|
3131
|
+
if (foreshadows.length) mountModuleLayoutToggle(layout, "伏笔列表样式");
|
|
3085
3132
|
const outlineHtml = outlines.length ? `<div class="outline-list">${outlines.map((item) => `
|
|
3086
3133
|
<article class="outline-row ${item.status === "completed" ? "is-complete" : ""}">
|
|
3087
3134
|
<div><small>${esc(item.volumeTitle)} · ${esc(item.status)}</small><h3>${esc(item.chapterTitle)}</h3></div>
|
|
@@ -3108,7 +3155,7 @@ async function renderRelationships() {
|
|
|
3108
3155
|
state.relationshipGraph = graph;
|
|
3109
3156
|
$("#module-content").innerHTML = `<div id="relationship-map-host"></div>${relationships.length ? `<table class="table-list relationship-table"><thead><tr><th>人物</th><th>关系</th><th>关键词</th><th>证据</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead><tbody>${relationships.map((item) => `
|
|
3110
3157
|
<tr><td>${esc(nameOf(item.fromCharacterId))} ${item.directed ? "→" : "—"} ${esc(nameOf(item.toCharacterId))}</td>
|
|
3111
|
-
<td>${esc(item.category)} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}
|
|
3158
|
+
<td>${esc(item.category)} / ${esc(item.subtype || "未细分")}</td><td>${(item.keywords ?? []).map((keyword) => `<span class="pill relationship-keyword">${esc(keyword)}</span>`).join("") || "—"}</td><td>${item.evidence.length}</td><td>${Math.round(item.confidence * 100)}%</td><td>${esc(item.confirmationStatus)}</td><td class="relationship-actions"><button data-edit-relationship="${esc(item.id)}">编辑</button><button data-entity-history="relationship" data-entity-id="${esc(item.id)}" data-entity-title="${esc(`${nameOf(item.fromCharacterId)} / ${nameOf(item.toCharacterId)}`)}">历史</button></td></tr>`).join("")}</tbody></table>` : '<div class="relationship-empty-note">尚无关系边;孤立角色仍显示在力导向图谱中。可人工新建关系,或运行全书人物关系分析。</div>'}`;
|
|
3112
3159
|
const openGalaxy = () => {
|
|
3113
3160
|
state.galaxy?.destroy();
|
|
3114
3161
|
state.galaxy = createGalaxyRenderer($("#relationship-galaxy-dialog"), graph, { workId: state.work.id });
|
|
@@ -3172,8 +3219,9 @@ async function renderReviews() {
|
|
|
3172
3219
|
<div class="card-actions">${item.status === "pending" && canResolveReview ? `<button data-review-status="fixed" data-review-id="${esc(item.id)}">标为已修复</button><button data-review-status="ignored" data-review-id="${esc(item.id)}">忽略</button>` : ""}</div>
|
|
3173
3220
|
</article>`;
|
|
3174
3221
|
};
|
|
3222
|
+
if (reviews.length) mountModuleLayoutToggle(layout, "审核列表样式");
|
|
3175
3223
|
$("#module-content").innerHTML = reviews.length
|
|
3176
|
-
? `${
|
|
3224
|
+
? `${layout === "rows" ? `<div class="module-row-list">${reviews.map(reviewRow).join("")}</div>` : `<div class="card-grid">${reviews.map(reviewCard).join("")}</div>`}`
|
|
3177
3225
|
: emptyModule("没有待审核事项", "候选设定、冲突与低置信度结论会集中显示在这里。");
|
|
3178
3226
|
bindModuleLayoutToggle(renderReviews);
|
|
3179
3227
|
$("#module-content").querySelectorAll("[data-review-id]").forEach((button) => button.addEventListener("click", async () => {
|
|
@@ -3973,8 +4021,12 @@ function openWorkSettingsDialog(work) {
|
|
|
3973
4021
|
<div><strong id="import-history-settings-title">正文导入历史</strong><small>查看导入前快照并恢复被覆盖的分卷、章节标题和正文;大纲、伏笔等章节关联资料不在快照中。</small></div>
|
|
3974
4022
|
<button id="import-history-button" class="ghost-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog" ${canOpenImportHistory ? "" : "disabled"}>${importHistoryAction}</button>
|
|
3975
4023
|
</section>`;
|
|
4024
|
+
const whitespaceField = isCurrentWork ? `<section class="work-access-field" aria-labelledby="whitespace-settings-title">
|
|
4025
|
+
<div><strong id="whitespace-settings-title">正文空白符</strong><small>在编辑器正文中显示或隐藏空格、全角空格和 Tab 的可视标记。</small></div>
|
|
4026
|
+
<button id="toggle-whitespace-settings" class="ghost-button" data-toggle-whitespace type="button" aria-pressed="${chapterWhitespaceVisible}" title="用点标记半角空格,用方框标记全角空格,用箭头标记 Tab">${chapterWhitespaceVisible ? "隐藏空白符" : "显示空白符"}</button>
|
|
4027
|
+
</section>` : "";
|
|
3976
4028
|
openDialog("作品信息",
|
|
3977
|
-
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + accessField + importHistoryField,
|
|
4029
|
+
workCoverFieldHtml(work) + field("title", "作品名称", "text", work.title) + field("author", "作者", "text", work.author) + field("description", "简介", "textarea", work.description) + whitespaceField + accessField + importHistoryField,
|
|
3978
4030
|
async (form) => {
|
|
3979
4031
|
await api(`/api/works/${work.id}`, { method: "PATCH", body: { title: form.get("title"), author: form.get("author"), description: form.get("description") } });
|
|
3980
4032
|
state.works = (await apiPage("/api/works")).items;
|
|
@@ -3986,12 +4038,13 @@ function openWorkSettingsDialog(work) {
|
|
|
3986
4038
|
state.work.description = String(form.get("description") ?? state.work.description);
|
|
3987
4039
|
if (updated?.coverUrl !== undefined) state.work.coverUrl = updated.coverUrl;
|
|
3988
4040
|
updateDocumentTitle(state.work);
|
|
3989
|
-
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${state.work.wordCount} 字`;
|
|
4041
|
+
$("#work-meta").textContent = `${state.work.title}${state.work.author ? ` · ${state.work.author}` : ""} · ${Number(state.work.wordCount ?? 0).toLocaleString("zh-CN")} 字`;
|
|
3990
4042
|
}
|
|
3991
4043
|
renderShelf();
|
|
3992
4044
|
toast("作品信息已保存");
|
|
3993
4045
|
}, "作品设置");
|
|
3994
4046
|
bindWorkCoverControls(work);
|
|
4047
|
+
$("#toggle-whitespace-settings")?.addEventListener("click", toggleChapterWhitespaceVisibility);
|
|
3995
4048
|
$("#import-history-button")?.addEventListener("click", () => {
|
|
3996
4049
|
$("#form-dialog").close();
|
|
3997
4050
|
void openImportHistory();
|
|
@@ -4057,6 +4110,43 @@ function openSettingEditor(item = null) {
|
|
|
4057
4110
|
$("#setting-editor-form").querySelectorAll("input, textarea").forEach((control) => { control.readOnly = viewOnly; });
|
|
4058
4111
|
$("#setting-editor-form").querySelectorAll("select, input[type='checkbox']").forEach((control) => { control.disabled = viewOnly; });
|
|
4059
4112
|
$("#setting-editor-submit").classList.toggle("hidden", viewOnly);
|
|
4113
|
+
const management = $("#setting-editor-management");
|
|
4114
|
+
management.classList.toggle("hidden", !item || viewOnly);
|
|
4115
|
+
const statusButtons = [$("#setting-editor-confirm"), $("#setting-editor-deprecate")];
|
|
4116
|
+
$("#setting-editor-confirm").classList.toggle("hidden", item?.status !== "pending");
|
|
4117
|
+
$("#setting-editor-deprecate").classList.toggle("hidden", item?.status !== "pending");
|
|
4118
|
+
$("#setting-editor-history").onclick = async () => {
|
|
4119
|
+
if (!item) return;
|
|
4120
|
+
if (!(await closeEntityEditor())) return;
|
|
4121
|
+
openEntityHistory("setting", item.id, item.title, async () => { await renderSettings(); await loadAiReferences(); });
|
|
4122
|
+
};
|
|
4123
|
+
$("#setting-editor-delete").onclick = () => {
|
|
4124
|
+
if (!item) return;
|
|
4125
|
+
void deleteManagedEntity({
|
|
4126
|
+
typeLabel: "设定",
|
|
4127
|
+
item,
|
|
4128
|
+
endpoint: (setting) => `/api/settings/${encodeURIComponent(setting.id)}`,
|
|
4129
|
+
refresh: () => closeEntityEditor({ force: true }),
|
|
4130
|
+
warning: "这条设定及其版本历史会被删除。"
|
|
4131
|
+
});
|
|
4132
|
+
};
|
|
4133
|
+
statusButtons.forEach((button) => {
|
|
4134
|
+
button.onclick = async () => {
|
|
4135
|
+
if (!item) return;
|
|
4136
|
+
button.disabled = true;
|
|
4137
|
+
const status = button.id === "setting-editor-confirm" ? "confirmed" : "deprecated";
|
|
4138
|
+
try {
|
|
4139
|
+
await api(`/api/settings/${item.id}`, { method: "PATCH", body: { status, changeNote: status === "confirmed" ? "确认 AI 设定候选" : "弃用 AI 设定候选" } });
|
|
4140
|
+
await closeEntityEditor({ force: true });
|
|
4141
|
+
await loadAiReferences();
|
|
4142
|
+
toast(status === "confirmed" ? "设定候选已确认" : "设定候选已弃用");
|
|
4143
|
+
} catch (error) {
|
|
4144
|
+
toast(error.message, "error");
|
|
4145
|
+
} finally {
|
|
4146
|
+
button.disabled = false;
|
|
4147
|
+
}
|
|
4148
|
+
};
|
|
4149
|
+
});
|
|
4060
4150
|
$("#setting-editor-form").onsubmit = async (event) => {
|
|
4061
4151
|
event.preventDefault();
|
|
4062
4152
|
if (!canEditModule("settings")) return;
|
|
@@ -4654,6 +4744,7 @@ function renderCharacterEditorFields(item) {
|
|
|
4654
4744
|
? field("firstChapterId", "首次登场章节", "select", item?.firstChapterId ?? "", chapterOptions)
|
|
4655
4745
|
: '<div class="character-editor-empty-field"><b>首次登场章节</b><span>当前账户没有正文读取权限,原有绑定不会被修改。</span></div>')),
|
|
4656
4746
|
characterEditorSection("profile", "人物档案", "记录人物定位、行为动力和便于创作时快速理解的简介。",
|
|
4747
|
+
field("code", "编号", "text", item?.code) +
|
|
4657
4748
|
field("identity", "身份与定位", "text", item?.attributes?.identity) +
|
|
4658
4749
|
field("motivation", "核心动机", "textarea", item?.profile?.motivation) +
|
|
4659
4750
|
field("summary", "人物简介", "textarea", item?.profile?.summary)),
|
|
@@ -4690,6 +4781,7 @@ function collectCharacterBody(form) {
|
|
|
4690
4781
|
delete profile.sections;
|
|
4691
4782
|
const body = {
|
|
4692
4783
|
name: String(form.get("name") ?? "").trim(),
|
|
4784
|
+
code: String(form.get("code") ?? "").trim(),
|
|
4693
4785
|
aliases: form.getAll("aliases").map((value) => String(value).trim()).filter(Boolean),
|
|
4694
4786
|
attributes: {
|
|
4695
4787
|
...(item?.attributes ?? {}),
|
|
@@ -4796,6 +4888,34 @@ async function openCharacterEditor(item = null) {
|
|
|
4796
4888
|
$("#character-editor-submit").textContent = item ? "保存新版本" : "创建人物档案";
|
|
4797
4889
|
$("#character-history-button").disabled = !item;
|
|
4798
4890
|
$("#character-history-button").title = item ? "查看、比较和回滚历史版本" : "创建人物档案后即可查看版本历史";
|
|
4891
|
+
const characterMergeButton = $("#character-merge-button");
|
|
4892
|
+
const characterDeleteButton = $("#character-delete-button");
|
|
4893
|
+
const canManageCharacter = Boolean(item && canEditModule("characters"));
|
|
4894
|
+
characterMergeButton.classList.toggle("hidden", !canManageCharacter || state.characters.length < 2);
|
|
4895
|
+
characterDeleteButton.classList.toggle("hidden", !canManageCharacter);
|
|
4896
|
+
characterMergeButton.onclick = async () => {
|
|
4897
|
+
if (!item) return;
|
|
4898
|
+
await closeEntityEditor({ force: true });
|
|
4899
|
+
openEntityMergeDialog({
|
|
4900
|
+
typeLabel: "角色",
|
|
4901
|
+
source: item,
|
|
4902
|
+
candidates: state.characters,
|
|
4903
|
+
endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}/merge`,
|
|
4904
|
+
body: (target) => ({ targetCharacterId: target.id, expectedTargetVersionNo: target.versionNo, expectedSourceVersionNo: item.versionNo }),
|
|
4905
|
+
refresh: renderCharacters,
|
|
4906
|
+
impact: "来源角色的别名、组织、档案章节、时间线与人物关系会迁移到目标角色。"
|
|
4907
|
+
});
|
|
4908
|
+
};
|
|
4909
|
+
characterDeleteButton.onclick = () => {
|
|
4910
|
+
if (!item) return;
|
|
4911
|
+
void deleteManagedEntity({
|
|
4912
|
+
typeLabel: "角色",
|
|
4913
|
+
item,
|
|
4914
|
+
endpoint: (character) => `/api/characters/${encodeURIComponent(character.id)}`,
|
|
4915
|
+
refresh: () => closeEntityEditor({ force: true }),
|
|
4916
|
+
warning: "相关人物关系会删除,时间线中的参与者引用会移除。"
|
|
4917
|
+
});
|
|
4918
|
+
};
|
|
4799
4919
|
setCharacterHistoryVisible(false);
|
|
4800
4920
|
renderCharacterEditorFields(item);
|
|
4801
4921
|
const viewOnly = !canEditModule("characters");
|
|
@@ -4900,6 +5020,43 @@ async function openKnowledgeEditor(kind, item) {
|
|
|
4900
5020
|
$("#knowledge-editor-header-note").textContent = isRace ? "层级、共同设定与角色归属" : "简介、组织设定与成员归属";
|
|
4901
5021
|
$("#knowledge-editor-footer-note").textContent = `保存${label}档案后返回${isRace ? "种族" : "组织"}列表。`;
|
|
4902
5022
|
$("#knowledge-editor-submit").textContent = item ? `保存${label}档案` : `创建${label}档案`;
|
|
5023
|
+
const historyButton = $("#knowledge-editor-history");
|
|
5024
|
+
const mergeButton = $("#knowledge-editor-merge");
|
|
5025
|
+
const deleteButton = $("#knowledge-editor-delete");
|
|
5026
|
+
const refresh = isRace ? renderRaces : renderOrganizations;
|
|
5027
|
+
const candidates = isRace ? state.races : state.organizations;
|
|
5028
|
+
const typeLabel = label;
|
|
5029
|
+
historyButton.classList.toggle("hidden", !item);
|
|
5030
|
+
mergeButton.classList.toggle("hidden", !item || !canEditModule(module) || candidates.length < 2);
|
|
5031
|
+
deleteButton.classList.toggle("hidden", !item || !canEditModule(module));
|
|
5032
|
+
historyButton.onclick = async () => {
|
|
5033
|
+
if (!item) return;
|
|
5034
|
+
if (!(await closeEntityEditor())) return;
|
|
5035
|
+
openEntityHistory(kind, item.id, item.name, async () => { await refresh(); await loadAiReferences(); });
|
|
5036
|
+
};
|
|
5037
|
+
mergeButton.onclick = async () => {
|
|
5038
|
+
if (!item) return;
|
|
5039
|
+
await closeEntityEditor({ force: true });
|
|
5040
|
+
openEntityMergeDialog({
|
|
5041
|
+
typeLabel,
|
|
5042
|
+
source: item,
|
|
5043
|
+
candidates,
|
|
5044
|
+
endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}/merge`,
|
|
5045
|
+
body: (target) => isRace ? { targetRaceId: target.id } : { targetOrganizationId: target.id },
|
|
5046
|
+
refresh,
|
|
5047
|
+
impact: isRace ? "来源种族的角色、子种族、简介与共同设定会迁移到目标种族。" : "来源组织的成员、简介与组织设定会迁移到目标组织。"
|
|
5048
|
+
});
|
|
5049
|
+
};
|
|
5050
|
+
deleteButton.onclick = () => {
|
|
5051
|
+
if (!item) return;
|
|
5052
|
+
void deleteManagedEntity({
|
|
5053
|
+
typeLabel,
|
|
5054
|
+
item,
|
|
5055
|
+
endpoint: (source) => `/api/${module}/${encodeURIComponent(source.id)}`,
|
|
5056
|
+
refresh: () => closeEntityEditor({ force: true }),
|
|
5057
|
+
warning: isRace ? "已绑定角色将变为未指定种族;有子种族时需先迁移或合并。" : "角色与该组织的成员关系会一并移除。"
|
|
5058
|
+
});
|
|
5059
|
+
};
|
|
4903
5060
|
renderKnowledgeEditorFields(kind, item, memberOptions, parentOptions);
|
|
4904
5061
|
const viewOnly = !canEditModule(module);
|
|
4905
5062
|
if (viewOnly) {
|
|
@@ -5867,6 +6024,7 @@ function cleanupExpandedRelationshipMap() {
|
|
|
5867
6024
|
$("#relationship-map-close").addEventListener("click", () => $("#relationship-map-dialog").close());
|
|
5868
6025
|
$("#relationship-map-dialog").addEventListener("close", cleanupExpandedRelationshipMap);
|
|
5869
6026
|
$("#appearance-button").addEventListener("click", openAppearanceDialog);
|
|
6027
|
+
$("#toggle-whitespace-appearance").addEventListener("click", toggleChapterWhitespaceVisibility);
|
|
5870
6028
|
$("#theme-toggle").addEventListener("click", () => {
|
|
5871
6029
|
const theme = nextTheme(currentColorTheme());
|
|
5872
6030
|
const persisted = saveColorTheme(theme);
|
|
@@ -5898,10 +6056,6 @@ $("#chapter-content").addEventListener("input", (event) => {
|
|
|
5898
6056
|
});
|
|
5899
6057
|
$("#chapter-content").addEventListener("select", scheduleAiContextUsage);
|
|
5900
6058
|
$("#chapter-content").addEventListener("scroll", syncChapterLineNumberScroll);
|
|
5901
|
-
$("#toggle-whitespace-button").addEventListener("click", () => {
|
|
5902
|
-
chapterWhitespaceVisible = !chapterWhitespaceVisible;
|
|
5903
|
-
scheduleChapterLineNumbers();
|
|
5904
|
-
});
|
|
5905
6059
|
$("#chapter-line-numbers-inner").addEventListener("pointerdown", (event) => {
|
|
5906
6060
|
const row = event.target.closest(".chapter-line-number");
|
|
5907
6061
|
if (!row || event.button !== 0) return;
|
|
@@ -6103,6 +6257,13 @@ document.addEventListener("keydown", (event) => {
|
|
|
6103
6257
|
hideAiMentionMenu();
|
|
6104
6258
|
}
|
|
6105
6259
|
});
|
|
6260
|
+
document.addEventListener("keydown", (event) => {
|
|
6261
|
+
if (!isGlobalSearchShortcut(event) || !state.work) return;
|
|
6262
|
+
event.preventDefault();
|
|
6263
|
+
event.stopPropagation();
|
|
6264
|
+
if (event.repeat) return;
|
|
6265
|
+
openSearchDialog().catch((error) => toast(error.message, "error"));
|
|
6266
|
+
}, { capture: true });
|
|
6106
6267
|
$("#ai-send").addEventListener("click", sendAi);
|
|
6107
6268
|
$("#ai-new-conversation").addEventListener("click", async () => {
|
|
6108
6269
|
const button = $("#ai-new-conversation");
|