@musnows/scriverse 0.3.9 → 0.3.10

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.
@@ -9,7 +9,7 @@
9
9
  <script src="/theme-init.js?v=20260720-route-skeleton"></script>
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
- <link rel="stylesheet" href="/styles.css?v=20260722-settings-import-mode">
12
+ <link rel="stylesheet" href="/styles.css?v=20260722-module-permissions-cr">
13
13
  </head>
14
14
  <body class="auth-pending">
15
15
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -529,9 +529,22 @@
529
529
  </dialog>
530
530
 
531
531
  <dialog id="members-dialog" class="dialog wide-dialog" aria-labelledby="members-dialog-title">
532
- <div class="dialog-header"><div><span id="members-dialog-eyebrow" class="eyebrow">作品权限</span><h2 id="members-dialog-title">可访问人</h2></div><button id="members-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
532
+ <div class="dialog-header"><div><span id="members-dialog-eyebrow" class="eyebrow">作品权限</span><h2 id="members-dialog-title">成员模块权限</h2></div><button id="members-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
533
533
  <div class="access-dialog-body">
534
- <form id="member-invite-form" class="member-invite-form"><label>邀请已注册用户<select id="member-user-select" required></select></label><label>访问权限<select id="member-role-select" required><option value="viewer">仅查看</option><option value="settings-editor">仅编辑设定</option><option value="editor">编辑正文与设定</option></select></label><button class="primary-button" type="submit">发送邀请</button></form>
534
+ <form id="member-permission-form" class="member-permission-form">
535
+ <label class="member-person-field">选择成员<select id="member-user-select" required></select><small>先选择已注册用户,再逐项配置模块权限。</small></label>
536
+ <fieldset id="member-permission-fieldset" disabled>
537
+ <legend>模块读写权限</legend>
538
+ <div class="member-permission-presets" aria-label="权限快捷设置">
539
+ <button type="button" data-permission-preset="none">全部关闭</button>
540
+ <button type="button" data-permission-preset="read">全部只读</button>
541
+ <button type="button" data-permission-preset="write">全部可编辑</button>
542
+ </div>
543
+ <div id="member-permission-grid" class="member-permission-grid"></div>
544
+ <small class="member-permission-help">“可编辑”自动包含读取权限;未授权模块不会显示,也不能通过 API 读取。</small>
545
+ <button id="member-permission-submit" class="primary-button" type="submit">保存成员权限</button>
546
+ </fieldset>
547
+ </form>
535
548
  <div id="members-list" class="access-list"></div>
536
549
  </div>
537
550
  </dialog>
@@ -598,6 +611,6 @@
598
611
  </dialog>
599
612
 
600
613
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
601
- <script type="module" src="/app.js?v=20260722-settings-import-version-lock"></script>
614
+ <script type="module" src="/app.js?v=20260722-module-permissions-cr"></script>
602
615
  </body>
603
616
  </html>
@@ -391,8 +391,8 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
391
391
  .settings-hub-card strong { align-self: end; font-size: 16px; }
392
392
  .settings-hub-card small { align-self: start; color: var(--muted); font-size: 10px; line-height: 1.5; }
393
393
  .settings-work-note { max-width: 1000px; margin: 18px auto 0; color: var(--muted); font-size: 11px; }
394
- .access-dialog-body { display: grid; gap: 16px; padding: 18px 24px 22px; }
395
- .access-list { display: grid; gap: 8px; max-height: min(52vh, 480px); overflow: auto; padding: 0; }
394
+ .access-dialog-body { display: grid; gap: 16px; max-height: calc(88vh - 82px); overflow-y: auto; padding: 18px 24px 22px; }
395
+ .access-list { display: grid; gap: 8px; max-height: min(34vh, 340px); overflow: auto; padding: 0; }
396
396
  .access-row {
397
397
  display: grid;
398
398
  grid-template-columns: minmax(0, 1fr) auto auto;
@@ -450,9 +450,10 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
450
450
  cursor: not-allowed;
451
451
  opacity: .46;
452
452
  }
453
- .member-invite-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, .3fr) auto; align-items: end; gap: 10px; margin: 0; }
454
- .member-invite-form label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
455
- .member-invite-form select {
453
+ .member-permission-form { display: grid; gap: 12px; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
454
+ .member-person-field { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
455
+ .member-person-field small, .member-permission-help { color: var(--muted); font-size: 10px; line-height: 1.5; }
456
+ .member-person-field select, .member-permission-row select {
456
457
  width: 100%;
457
458
  min-height: 38px;
458
459
  padding: 9px 10px;
@@ -462,11 +463,20 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
462
463
  color: var(--ink);
463
464
  font-size: 13px;
464
465
  }
465
- .member-invite-form .primary-button { min-height: 38px; white-space: nowrap; }
466
- .app-shell.view-only-mode:not(.shelf-mode) { grid-template-columns: var(--left-panel-width) minmax(480px, 1fr); }
467
- .app-shell.view-only-mode:not(.shelf-mode) .ai-panel { display: none; }
468
- .app-shell.settings-only-mode:not(.shelf-mode) { grid-template-columns: var(--left-panel-width) minmax(480px, 1fr); }
469
- .app-shell.settings-only-mode:not(.shelf-mode) .ai-panel { display: none; }
466
+ .member-permission-form fieldset { display: grid; gap: 12px; min-width: 0; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 6px; }
467
+ .member-permission-form fieldset:disabled { opacity: .58; }
468
+ .member-permission-form legend { padding: 0 6px; color: var(--ink); font-size: 12px; font-weight: 650; }
469
+ .member-permission-presets { display: flex; flex-wrap: wrap; gap: 6px; }
470
+ .member-permission-presets button { padding: 6px 9px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--muted); font-size: 10px; }
471
+ .member-permission-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; }
472
+ .member-permission-row { display: grid; grid-template-columns: minmax(90px, 1fr) minmax(112px, .7fr); align-items: center; gap: 8px; color: var(--ink); font-size: 11px; }
473
+ .member-permission-row select { min-height: 34px; padding-block: 6px; font-size: 11px; }
474
+ .member-permission-form .primary-button { justify-self: end; min-height: 38px; white-space: nowrap; }
475
+ .permission-hidden { display: none !important; }
476
+ .app-shell.ai-hidden-mode:not(.shelf-mode) { grid-template-columns: var(--left-panel-width) minmax(480px, 1fr); }
477
+ .app-shell.prose-hidden-mode:not(.shelf-mode) .left-actions,
478
+ .app-shell.prose-hidden-mode:not(.shelf-mode) .panel-heading,
479
+ .app-shell.prose-hidden-mode:not(.shelf-mode) #novel-tree { display: none !important; }
470
480
  .app-shell.prose-read-only-mode:not(.shelf-mode) .left-primary-actions .file-button,
471
481
  .app-shell.prose-read-only-mode:not(.shelf-mode) #new-volume-button,
472
482
  .app-shell.prose-read-only-mode:not(.shelf-mode) #new-chapter-button,
@@ -475,16 +485,11 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
475
485
  .app-shell.prose-read-only-mode:not(.shelf-mode) .left-primary-actions { justify-content: flex-end; }
476
486
  .app-shell.prose-read-only-mode:not(.shelf-mode) #chapter-title[readonly],
477
487
  .app-shell.prose-read-only-mode:not(.shelf-mode) #chapter-content[readonly] { cursor: default; }
478
- .app-shell.settings-only-mode:not(.shelf-mode) [data-work-settings],
479
- .app-shell.settings-only-mode:not(.shelf-mode) [data-module="tasks"],
480
- .app-shell.settings-only-mode:not(.shelf-mode) [data-module="ai-settings"] { display: none !important; }
481
488
  .app-shell.view-only-mode:not(.shelf-mode) .left-primary-actions .file-button,
482
489
  .app-shell.view-only-mode:not(.shelf-mode) #new-volume-button,
483
490
  .app-shell.view-only-mode:not(.shelf-mode) #new-chapter-button,
484
491
  .app-shell.view-only-mode:not(.shelf-mode) #module-create-button,
485
492
  .app-shell.view-only-mode:not(.shelf-mode) [data-work-settings],
486
- .app-shell.view-only-mode:not(.shelf-mode) [data-module="tasks"],
487
- .app-shell.view-only-mode:not(.shelf-mode) [data-module="ai-settings"],
488
493
  .app-shell.view-only-mode:not(.shelf-mode) #tidy-blank-lines-button,
489
494
  .app-shell.view-only-mode:not(.shelf-mode) #save-button { display: none !important; }
490
495
  .app-shell.view-only-mode:not(.shelf-mode) .left-primary-actions { justify-content: flex-end; }
@@ -508,6 +513,8 @@ body.work-viewer-mode [data-edit-relationship],
508
513
  body.work-viewer-mode [data-character-relationship-edit],
509
514
  body.work-viewer-mode [data-character-relationship-create],
510
515
  body.work-viewer-mode [data-review-id],
516
+ body.work-viewer-mode [data-merge-review],
517
+ body.work-viewer-mode [data-keep-characters-separate],
511
518
  body.work-viewer-mode .task-auto-run-panel,
512
519
  body.work-viewer-mode [data-run-task],
513
520
  body.work-viewer-mode [data-cancel-task],
@@ -519,11 +526,6 @@ body.work-viewer-mode [data-item-list-remove],
519
526
  body.work-viewer-mode [data-structured-list-add],
520
527
  body.work-viewer-mode [data-structured-list-remove],
521
528
  body.work-viewer-mode .character-editor-actions { display: none !important; }
522
- body.work-settings-editor-mode [data-review-id],
523
- body.work-settings-editor-mode .task-auto-run-panel,
524
- body.work-settings-editor-mode [data-run-task],
525
- body.work-settings-editor-mode [data-cancel-task],
526
- body.work-settings-editor-mode [data-restore-version] { display: none !important; }
527
529
  .search-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 10px; margin: 0; }
528
530
  .search-form label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
529
531
  .search-form input {
@@ -589,10 +591,12 @@ body.work-settings-editor-mode [data-restore-version] { display: none !important
589
591
  -webkit-box-orient: vertical;
590
592
  }
591
593
  .work-access-field { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
592
- .work-access-field > div { display: grid; gap: 4px; min-width: 0; }
594
+ .work-access-field > div { display: grid; gap: 6px; min-width: 0; }
593
595
  .work-access-field strong { color: var(--ink); font-size: 12px; }
594
596
  .work-access-field small { color: var(--muted); font-size: 10px; line-height: 1.5; }
595
597
  .work-access-field .ghost-button { flex: 0 0 auto; white-space: nowrap; }
598
+ .work-access-options { display: flex; flex-wrap: wrap; gap: 5px; }
599
+ .work-access-options span { padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--ink); font-size: 10px; line-height: 1.35; }
596
600
  .account-dialog { width: min(640px, 92vw); overflow: hidden; }
597
601
  .account-settings-body { display: grid; gap: 16px; max-height: calc(88vh - 86px); overflow-y: auto; padding: 20px 24px 24px; }
598
602
  .account-settings-section { padding: 18px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
@@ -1590,7 +1594,12 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1590
1594
  .relationship-table .relationship-actions button + button { margin-left: 5px; }
1591
1595
 
1592
1596
  @media (max-width: 540px) {
1593
- .member-invite-form { grid-template-columns: minmax(0, 1fr); }
1597
+ .member-permission-grid { grid-template-columns: minmax(0, 1fr); }
1598
+ .member-permission-row { grid-template-columns: minmax(0, 1fr); }
1599
+ .member-permission-form .primary-button { justify-self: stretch; }
1600
+ .access-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
1601
+ .access-row select, .access-row button { width: 100%; }
1602
+ .work-access-field { align-items: stretch; flex-direction: column; }
1594
1603
  .onboarding-popover { width: calc(100vw - 24px); }
1595
1604
  .onboarding-popover-header { padding-inline: 15px; }
1596
1605
  .onboarding-popover-body { padding: 16px 17px 18px; }
@@ -0,0 +1,14 @@
1
+ export type WorkPermissionModule = "prose" | "settings" | "characters" | "races" | "organizations" | "timeline" | "relationships" | "outlines" | "reviews" | "ai" | "ai-settings";
2
+ export type WorkUiModule = "editor" | "settings" | "characters" | "races" | "organizations" | "timeline" | "relationships" | "outlines" | "reviews" | "tasks" | "ai-settings";
3
+ export type WorkModuleAccess = "none" | "read" | "write";
4
+ export type WorkModulePermissions = Record<WorkPermissionModule, WorkModuleAccess>;
5
+ export type WorkPermissionAware = { accessRole?: string | null; modulePermissions?: WorkModulePermissions };
6
+
7
+ export const WORK_PERMISSION_MODULES: readonly Readonly<{ id: WorkPermissionModule; uiModule: WorkUiModule; label: string }>[];
8
+ export function emptyModulePermissions(): WorkModulePermissions;
9
+ export function normalizeModulePermissions(value: unknown, accessRole?: string): WorkModulePermissions;
10
+ export function permissionModuleForUiModule(uiModule: string): WorkPermissionModule;
11
+ export function canReadUiModule(work: WorkPermissionAware | null | undefined, uiModule: string): boolean;
12
+ export function canWriteUiModule(work: WorkPermissionAware | null | undefined, uiModule: string): boolean;
13
+ export function firstReadableUiModule(work: WorkPermissionAware | null | undefined): WorkUiModule | null;
14
+ export function permissionSummary(value: unknown): string;
@@ -0,0 +1,71 @@
1
+ export const WORK_PERMISSION_MODULES = Object.freeze([
2
+ { id: "prose", uiModule: "editor", label: "正文" },
3
+ { id: "settings", uiModule: "settings", label: "设定库" },
4
+ { id: "characters", uiModule: "characters", label: "角色" },
5
+ { id: "races", uiModule: "races", label: "种族" },
6
+ { id: "organizations", uiModule: "organizations", label: "组织" },
7
+ { id: "timeline", uiModule: "timeline", label: "时间轴" },
8
+ { id: "relationships", uiModule: "relationships", label: "关系" },
9
+ { id: "outlines", uiModule: "outlines", label: "大纲与伏笔" },
10
+ { id: "reviews", uiModule: "reviews", label: "审核" },
11
+ { id: "ai", uiModule: "tasks", label: "AI 对话与分析" },
12
+ { id: "ai-settings", uiModule: "ai-settings", label: "AI 设置" }
13
+ ]);
14
+
15
+ const permissionModuleByUiModule = new Map(WORK_PERMISSION_MODULES.map((item) => [item.uiModule, item.id]));
16
+ const validAccess = new Set(["none", "read", "write"]);
17
+
18
+ export function emptyModulePermissions() {
19
+ return Object.fromEntries(WORK_PERMISSION_MODULES.map((item) => [item.id, "none"]));
20
+ }
21
+
22
+ export function normalizeModulePermissions(value, accessRole = "viewer") {
23
+ if (value && typeof value === "object" && !Array.isArray(value)
24
+ && WORK_PERMISSION_MODULES.every((item) => validAccess.has(value[item.id]))) {
25
+ return Object.fromEntries(WORK_PERMISSION_MODULES.map((item) => [item.id, value[item.id]]));
26
+ }
27
+ const fallback = emptyModulePermissions();
28
+ for (const item of WORK_PERMISSION_MODULES) {
29
+ fallback[item.id] = accessRole === "editor" || accessRole === "admin" || accessRole === "owner"
30
+ ? "write"
31
+ : accessRole === "viewer" || accessRole === "settings-editor" ? "read" : "none";
32
+ }
33
+ if (accessRole === "settings-editor") {
34
+ fallback.prose = "read";
35
+ fallback.reviews = "read";
36
+ fallback.ai = "read";
37
+ fallback["ai-settings"] = "read";
38
+ }
39
+ return fallback;
40
+ }
41
+
42
+ export function permissionModuleForUiModule(uiModule) {
43
+ return permissionModuleByUiModule.get(String(uiModule)) ?? "prose";
44
+ }
45
+
46
+ export function canReadUiModule(work, uiModule) {
47
+ if (!work) return false;
48
+ const permissions = normalizeModulePermissions(work.modulePermissions, work.accessRole);
49
+ return permissions[permissionModuleForUiModule(uiModule)] !== "none";
50
+ }
51
+
52
+ export function canWriteUiModule(work, uiModule) {
53
+ if (!work) return false;
54
+ const permissions = normalizeModulePermissions(work.modulePermissions, work.accessRole);
55
+ return permissions[permissionModuleForUiModule(uiModule)] === "write";
56
+ }
57
+
58
+ export function firstReadableUiModule(work) {
59
+ return WORK_PERMISSION_MODULES.find((item) => canReadUiModule(work, item.uiModule))?.uiModule ?? null;
60
+ }
61
+
62
+ export function permissionSummary(value) {
63
+ const permissions = normalizeModulePermissions(value, "custom");
64
+ const writable = WORK_PERMISSION_MODULES.filter((item) => permissions[item.id] === "write").map((item) => item.label);
65
+ const readable = WORK_PERMISSION_MODULES.filter((item) => permissions[item.id] === "read").map((item) => item.label);
66
+ const parts = [];
67
+ if (writable.length) parts.push(`可编辑:${writable.join("、")}`);
68
+ if (readable.length) parts.push(`只读:${readable.join("、")}`);
69
+ if (!parts.length) parts.push("未授权任何模块");
70
+ return parts.join(";");
71
+ }
package/dist/store.js CHANGED
@@ -4,6 +4,7 @@ import { AppError, notFound } from "./errors.js";
4
4
  import { accountReference, logger } from "./logger.js";
5
5
  import { paginated, paginationSql } from "./pagination.js";
6
6
  import { currentRequestActor } from "./request-context.js";
7
+ import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
7
8
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
8
9
  export const versionedEntityTypes = [
9
10
  "work",
@@ -603,6 +604,9 @@ export class Store {
603
604
  }
604
605
  getWorkDirectory(workId) {
605
606
  const work = this.getWork(workId);
607
+ const permissions = work.modulePermissions;
608
+ if (permissions.prose === "none")
609
+ return { ...work, volumes: [] };
606
610
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
607
611
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
608
612
  analysis_status, excluded_from_analysis, created_at, updated_at
@@ -623,6 +627,9 @@ export class Store {
623
627
  }
624
628
  getWorkDirectoryPage(workId, pagination) {
625
629
  const work = this.getWork(workId);
630
+ const permissions = work.modulePermissions;
631
+ if (permissions.prose === "none")
632
+ return { ...work, volumes: [], directoryPage: paginated([], pagination) };
626
633
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
627
634
  const page = paginationSql(pagination);
628
635
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
@@ -1193,15 +1200,19 @@ export class Store {
1193
1200
  const membership = actor
1194
1201
  ? this.db.get("SELECT role, permissions_json FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
1195
1202
  : undefined;
1196
- const membershipPermissions = json(optionalString(membership ?? {}, "permissions_json"), {});
1197
1203
  const membershipRole = String(membership?.role ?? "");
1204
+ const ownerAccess = ownerUserId === actor?.userId;
1205
+ const adminAccess = actor?.role === "admin" && actor.authentication !== "api-key";
1206
+ const modulePermissions = !actor || ownerAccess || adminAccess
1207
+ ? fullWorkModulePermissions()
1208
+ : membershipRole
1209
+ ? storedWorkModulePermissions(membershipRole, optionalString(membership ?? {}, "permissions_json"))
1210
+ : emptyWorkModulePermissions();
1198
1211
  const accessRole = ownerUserId === actor?.userId
1199
1212
  ? "owner"
1200
- : actor?.role === "admin"
1213
+ : adminAccess
1201
1214
  ? "admin"
1202
- : membershipRole === "editor" && membershipPermissions.editScope === "settings"
1203
- ? "settings-editor"
1204
- : ["editor", "viewer"].includes(membershipRole) ? membershipRole : null;
1215
+ : membershipRole ? classifyWorkModulePermissions(modulePermissions) : null;
1205
1216
  const count = this.db.get("SELECT COUNT(*) AS chapter_count, COALESCE(SUM(word_count), 0) AS word_count FROM chapters WHERE work_id = ?", requiredString(row, "id"));
1206
1217
  const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
1207
1218
  return {
@@ -1217,8 +1228,9 @@ export class Store {
1217
1228
  versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", requiredString(row, "id")),
1218
1229
  ownerUserId,
1219
1230
  accessRole,
1220
- chapterCount: numberValue(count ?? {}, "chapter_count"),
1221
- wordCount: numberValue(count ?? {}, "word_count"),
1231
+ modulePermissions,
1232
+ chapterCount: modulePermissions.prose === "none" ? 0 : numberValue(count ?? {}, "chapter_count"),
1233
+ wordCount: modulePermissions.prose === "none" ? 0 : numberValue(count ?? {}, "word_count"),
1222
1234
  createdAt: requiredString(row, "created_at"),
1223
1235
  updatedAt: requiredString(row, "updated_at")
1224
1236
  };