@musnows/scriverse 0.3.9 → 0.3.11
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/dist/app.js +167 -31
- package/dist/app.js.map +1 -1
- package/dist/public/app.js +387 -125
- package/dist/public/index.html +31 -6
- package/dist/public/styles.css +40 -22
- package/dist/public/work-permissions.d.ts +14 -0
- package/dist/public/work-permissions.js +71 -0
- package/dist/store.js +89 -23
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +203 -52
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/work-permissions.js +129 -0
- package/dist/work-permissions.js.map +1 -0
- package/package.json +1 -1
package/dist/public/index.html
CHANGED
|
@@ -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-
|
|
12
|
+
<link rel="stylesheet" href="/styles.css?v=20260722-prose-replacement-permission">
|
|
13
13
|
</head>
|
|
14
14
|
<body class="auth-pending">
|
|
15
15
|
<section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
<div id="left-panel-resize" class="panel-resize-handle" role="separator" aria-label="调整作品侧栏宽度" aria-orientation="vertical" tabindex="0"></div>
|
|
92
92
|
<div class="left-actions">
|
|
93
93
|
<div class="left-primary-actions">
|
|
94
|
-
<label class="file-button" aria-label="导入 TXT / DOCX">
|
|
94
|
+
<label id="import-file-button" class="file-button" aria-label="导入 TXT / DOCX">
|
|
95
95
|
<span class="import-file-label import-file-label-full" aria-hidden="true">导入 TXT / DOCX</span>
|
|
96
96
|
<span class="import-file-label import-file-label-compact" aria-hidden="true">导入TXT/DOCX</span>
|
|
97
97
|
<span class="import-file-label import-file-label-short" aria-hidden="true">导入</span>
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
</label>
|
|
100
100
|
<button id="left-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起作品侧栏" aria-expanded="true">‹</button>
|
|
101
101
|
</div>
|
|
102
|
+
<button id="import-history-button" class="secondary-button import-history-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog">导入历史与恢复</button>
|
|
102
103
|
<button id="new-volume-button" class="secondary-button" type="button">新建分卷</button>
|
|
103
104
|
<button id="new-chapter-button" class="secondary-button" type="button">新建章节</button>
|
|
104
105
|
</div>
|
|
@@ -328,7 +329,7 @@
|
|
|
328
329
|
<p id="import-mode-unsaved-warning" class="import-mode-unsaved-warning hidden" role="alert">当前章节有未保存修改,继续导入会丢失这些本地修改。</p>
|
|
329
330
|
<div class="import-mode-options" aria-label="导入方式说明">
|
|
330
331
|
<section><strong>追加正文</strong><span>保留现有卷章,把新文件解析出的卷章添加到目录末尾。</span></section>
|
|
331
|
-
<section><strong>覆盖正文</strong><span>删除现有正文目录后写入新文件;导入前会保留可恢复的版本快照。</span></section>
|
|
332
|
+
<section><strong>覆盖正文</strong><span>删除现有正文目录后写入新文件;导入前会保留可恢复的版本快照。</span><small id="import-mode-overwrite-permission" class="import-mode-permission hidden">覆盖会影响章节关联资料,需要所有受影响模块均为可编辑。</small></section>
|
|
332
333
|
</div>
|
|
333
334
|
</div>
|
|
334
335
|
<div class="dialog-actions">
|
|
@@ -480,6 +481,17 @@
|
|
|
480
481
|
<div id="versions-list" class="versions-list"></div>
|
|
481
482
|
</dialog>
|
|
482
483
|
|
|
484
|
+
<dialog id="import-history-dialog" class="dialog wide-dialog" aria-labelledby="import-history-title" aria-describedby="import-history-description">
|
|
485
|
+
<div class="dialog-header">
|
|
486
|
+
<div><span class="eyebrow">正文安全</span><h2 id="import-history-title">导入历史与正文恢复</h2></div>
|
|
487
|
+
<button id="import-history-close" class="dialog-close" aria-label="关闭导入历史" type="button">×</button>
|
|
488
|
+
</div>
|
|
489
|
+
<div class="import-history-body">
|
|
490
|
+
<p id="import-history-description" class="import-history-note">每条记录都保存了该次导入开始前的正文。恢复仅作用于分卷、章节标题和正文,并会重新建立分卷和章节;大纲、伏笔、首次登场等章节关联信息不在快照中,无法通过这里恢复。</p>
|
|
491
|
+
<div id="import-history-list" class="entity-history-list" aria-live="polite"></div>
|
|
492
|
+
</div>
|
|
493
|
+
</dialog>
|
|
494
|
+
|
|
483
495
|
<dialog id="users-dialog" class="dialog wide-dialog" aria-labelledby="users-dialog-title">
|
|
484
496
|
<div class="dialog-header"><div><span class="eyebrow">系统管理员</span><h2 id="users-dialog-title">用户管理</h2></div><button id="users-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
|
|
485
497
|
<div class="access-dialog-body">
|
|
@@ -529,9 +541,22 @@
|
|
|
529
541
|
</dialog>
|
|
530
542
|
|
|
531
543
|
<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"
|
|
544
|
+
<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
545
|
<div class="access-dialog-body">
|
|
534
|
-
<form id="member-
|
|
546
|
+
<form id="member-permission-form" class="member-permission-form">
|
|
547
|
+
<label class="member-person-field">选择成员<select id="member-user-select" required></select><small>先选择已注册用户,再逐项配置模块权限。</small></label>
|
|
548
|
+
<fieldset id="member-permission-fieldset" disabled>
|
|
549
|
+
<legend>模块读写权限</legend>
|
|
550
|
+
<div class="member-permission-presets" aria-label="权限快捷设置">
|
|
551
|
+
<button type="button" data-permission-preset="none">全部关闭</button>
|
|
552
|
+
<button type="button" data-permission-preset="read">全部只读</button>
|
|
553
|
+
<button type="button" data-permission-preset="write">全部可编辑</button>
|
|
554
|
+
</div>
|
|
555
|
+
<div id="member-permission-grid" class="member-permission-grid"></div>
|
|
556
|
+
<small class="member-permission-help">“可编辑”自动包含读取权限;未授权模块不会显示,也不能通过 API 读取。</small>
|
|
557
|
+
<button id="member-permission-submit" class="primary-button" type="submit">保存成员权限</button>
|
|
558
|
+
</fieldset>
|
|
559
|
+
</form>
|
|
535
560
|
<div id="members-list" class="access-list"></div>
|
|
536
561
|
</div>
|
|
537
562
|
</dialog>
|
|
@@ -598,6 +623,6 @@
|
|
|
598
623
|
</dialog>
|
|
599
624
|
|
|
600
625
|
<div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
|
|
601
|
-
<script type="module" src="/app.js?v=20260722-
|
|
626
|
+
<script type="module" src="/app.js?v=20260722-prose-replacement-permission"></script>
|
|
602
627
|
</body>
|
|
603
628
|
</html>
|
package/dist/public/styles.css
CHANGED
|
@@ -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(
|
|
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-
|
|
454
|
-
.member-
|
|
455
|
-
.member-
|
|
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-
|
|
466
|
-
.
|
|
467
|
-
.
|
|
468
|
-
.
|
|
469
|
-
.
|
|
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:
|
|
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); }
|
|
@@ -702,6 +706,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
702
706
|
.left-primary-actions { display: flex; grid-column: 1 / -1; width: 100%; align-items: center; gap: 8px; }
|
|
703
707
|
.left-primary-actions .file-button { flex: 1 1 auto; width: auto; min-width: 0; }
|
|
704
708
|
.left-primary-actions #left-panel-toggle { flex: 0 0 30px; width: 30px; height: 30px; }
|
|
709
|
+
.import-history-button { grid-column: 1 / -1; }
|
|
705
710
|
.file-button, .secondary-button { display: grid; place-items: center; min-height: 30px; border: 1px solid var(--line); border-radius: 4px; font-size: 11px; background: transparent; }
|
|
706
711
|
.file-button { container-type: inline-size; overflow: hidden; white-space: nowrap; }
|
|
707
712
|
.import-file-label-compact, .import-file-label-short { display: none; }
|
|
@@ -1414,6 +1419,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1414
1419
|
.import-mode-options section { display: grid; gap: 5px; min-width: 0; padding: 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
|
|
1415
1420
|
.import-mode-options strong { color: var(--ink); font-size: 12px; }
|
|
1416
1421
|
.import-mode-options span { color: var(--muted); font-size: 10px; line-height: 1.55; }
|
|
1422
|
+
.import-mode-permission { color: var(--accent-dark); font-size: 9px; line-height: 1.55; }
|
|
1417
1423
|
@media (max-width: 560px) {
|
|
1418
1424
|
.import-mode-options { grid-template-columns: 1fr; }
|
|
1419
1425
|
.import-mode-dialog .dialog-actions { flex-wrap: wrap; }
|
|
@@ -1573,6 +1579,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1573
1579
|
.version-row { display: grid; grid-template-columns: 75px 1fr auto; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--line); align-items: start; }
|
|
1574
1580
|
.version-row p { margin: 0; color: var(--muted); font-size: 11px; white-space: pre-wrap; max-height: 70px; overflow: hidden; }
|
|
1575
1581
|
.entity-history-list { display: grid; gap: 10px; padding: 18px 24px 28px; max-height: 65vh; overflow: auto; }
|
|
1582
|
+
.import-history-body { min-height: 220px; }
|
|
1583
|
+
.import-history-note { margin: 0; padding: 16px 24px 0; color: var(--muted); font-size: 10px; line-height: 1.65; }
|
|
1584
|
+
.import-history-body .entity-history-list { padding-top: 14px; }
|
|
1585
|
+
.import-history-card header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1586
|
+
.import-history-card .import-history-kind { flex: none; }
|
|
1587
|
+
.import-history-load-more { justify-self: center; min-height: 34px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); font-size: 10px; }
|
|
1588
|
+
.import-history-load-more:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-dark); }
|
|
1576
1589
|
.entity-version-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; padding: 14px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
|
|
1577
1590
|
.entity-version-card.is-current { border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); box-shadow: inset 3px 0 var(--accent); }
|
|
1578
1591
|
.entity-version-card header { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
@@ -1590,7 +1603,12 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1590
1603
|
.relationship-table .relationship-actions button + button { margin-left: 5px; }
|
|
1591
1604
|
|
|
1592
1605
|
@media (max-width: 540px) {
|
|
1593
|
-
.member-
|
|
1606
|
+
.member-permission-grid { grid-template-columns: minmax(0, 1fr); }
|
|
1607
|
+
.member-permission-row { grid-template-columns: minmax(0, 1fr); }
|
|
1608
|
+
.member-permission-form .primary-button { justify-self: stretch; }
|
|
1609
|
+
.access-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
|
|
1610
|
+
.access-row select, .access-row button { width: 100%; }
|
|
1611
|
+
.work-access-field { align-items: stretch; flex-direction: column; }
|
|
1594
1612
|
.onboarding-popover { width: calc(100vw - 24px); }
|
|
1595
1613
|
.onboarding-popover-header { padding-inline: 15px; }
|
|
1596
1614
|
.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",
|
|
@@ -17,6 +18,65 @@ export const versionedEntityTypes = [
|
|
|
17
18
|
"chapter-outline",
|
|
18
19
|
"foreshadow"
|
|
19
20
|
];
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
function invalidFileSnapshot() {
|
|
25
|
+
throw new AppError(409, "FILE_VERSION_INVALID", "正文历史快照已损坏,未执行恢复");
|
|
26
|
+
}
|
|
27
|
+
function parseRestorableFileSnapshot(value, workId) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
invalidFileSnapshot();
|
|
34
|
+
}
|
|
35
|
+
if (!isRecord(parsed) || parsed.id !== workId || !Array.isArray(parsed.volumes) || parsed.volumes.length > 10_000) {
|
|
36
|
+
return invalidFileSnapshot();
|
|
37
|
+
}
|
|
38
|
+
let chapterCount = 0;
|
|
39
|
+
let contentLength = 0;
|
|
40
|
+
const volumes = parsed.volumes.map((volumeValue) => {
|
|
41
|
+
if (!isRecord(volumeValue) || typeof volumeValue.title !== "string" || typeof volumeValue.kind !== "string"
|
|
42
|
+
|| typeof volumeValue.source !== "string" || typeof volumeValue.sortOrder !== "number"
|
|
43
|
+
|| !Number.isFinite(volumeValue.sortOrder) || !Array.isArray(volumeValue.chapters)) {
|
|
44
|
+
return invalidFileSnapshot();
|
|
45
|
+
}
|
|
46
|
+
const description = volumeValue.description === undefined ? "" : volumeValue.description;
|
|
47
|
+
const keywords = volumeValue.keywords === undefined ? [] : volumeValue.keywords;
|
|
48
|
+
if (typeof description !== "string" || !Array.isArray(keywords) || !keywords.every((keyword) => typeof keyword === "string")) {
|
|
49
|
+
return invalidFileSnapshot();
|
|
50
|
+
}
|
|
51
|
+
const chapters = volumeValue.chapters.map((chapterValue) => {
|
|
52
|
+
if (!isRecord(chapterValue) || typeof chapterValue.title !== "string" || typeof chapterValue.content !== "string"
|
|
53
|
+
|| typeof chapterValue.sortOrder !== "number" || !Number.isFinite(chapterValue.sortOrder)
|
|
54
|
+
|| !["正文", "设定", "作者的话", "其他"].includes(String(chapterValue.chapterType))) {
|
|
55
|
+
return invalidFileSnapshot();
|
|
56
|
+
}
|
|
57
|
+
chapterCount += 1;
|
|
58
|
+
contentLength += chapterValue.content.length;
|
|
59
|
+
if (chapterCount > 100_000 || contentLength > 20_000_000)
|
|
60
|
+
return invalidFileSnapshot();
|
|
61
|
+
return {
|
|
62
|
+
title: chapterValue.title,
|
|
63
|
+
content: chapterValue.content,
|
|
64
|
+
sortOrder: chapterValue.sortOrder,
|
|
65
|
+
chapterType: chapterValue.chapterType
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
title: volumeValue.title,
|
|
70
|
+
kind: volumeValue.kind,
|
|
71
|
+
source: volumeValue.source,
|
|
72
|
+
description,
|
|
73
|
+
keywords: [...keywords],
|
|
74
|
+
sortOrder: volumeValue.sortOrder,
|
|
75
|
+
chapters
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
return { volumes };
|
|
79
|
+
}
|
|
20
80
|
function requiredString(row, key) {
|
|
21
81
|
return String(row[key] ?? "");
|
|
22
82
|
}
|
|
@@ -603,6 +663,9 @@ export class Store {
|
|
|
603
663
|
}
|
|
604
664
|
getWorkDirectory(workId) {
|
|
605
665
|
const work = this.getWork(workId);
|
|
666
|
+
const permissions = work.modulePermissions;
|
|
667
|
+
if (permissions.prose === "none")
|
|
668
|
+
return { ...work, volumes: [] };
|
|
606
669
|
const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
|
|
607
670
|
const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
|
|
608
671
|
analysis_status, excluded_from_analysis, created_at, updated_at
|
|
@@ -623,6 +686,9 @@ export class Store {
|
|
|
623
686
|
}
|
|
624
687
|
getWorkDirectoryPage(workId, pagination) {
|
|
625
688
|
const work = this.getWork(workId);
|
|
689
|
+
const permissions = work.modulePermissions;
|
|
690
|
+
if (permissions.prose === "none")
|
|
691
|
+
return { ...work, volumes: [], directoryPage: paginated([], pagination) };
|
|
626
692
|
const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
|
|
627
693
|
const page = paginationSql(pagination);
|
|
628
694
|
const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
|
|
@@ -648,7 +714,7 @@ export class Store {
|
|
|
648
714
|
.all(`SELECT version.id, version.work_id, version.file_name, version.file_type, version.word_count, version.paragraph_count,
|
|
649
715
|
version.warnings_json, version.created_at, user.display_name AS actor_display_name, user.username AS actor_username
|
|
650
716
|
FROM file_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
651
|
-
WHERE version.work_id = ? ORDER BY version.created_at DESC`, workId)
|
|
717
|
+
WHERE version.work_id = ? ORDER BY version.created_at DESC, version.id DESC`, workId)
|
|
652
718
|
.map((row) => ({
|
|
653
719
|
id: requiredString(row, "id"),
|
|
654
720
|
workId: requiredString(row, "work_id"),
|
|
@@ -667,7 +733,7 @@ export class Store {
|
|
|
667
733
|
const rows = this.db.all(`SELECT version.id, version.work_id, version.file_name, version.file_type, version.word_count, version.paragraph_count,
|
|
668
734
|
version.warnings_json, version.created_at, user.display_name AS actor_display_name, user.username AS actor_username
|
|
669
735
|
FROM file_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
670
|
-
WHERE version.work_id = ? ORDER BY version.created_at DESC${page.sql}`, workId, ...page.params);
|
|
736
|
+
WHERE version.work_id = ? ORDER BY version.created_at DESC, version.id DESC${page.sql}`, workId, ...page.params);
|
|
671
737
|
return paginated(rows.map((row) => ({
|
|
672
738
|
id: requiredString(row, "id"),
|
|
673
739
|
workId: requiredString(row, "work_id"),
|
|
@@ -685,8 +751,7 @@ export class Store {
|
|
|
685
751
|
const version = this.db.get("SELECT * FROM file_versions WHERE id = ? AND work_id = ?", fileVersionId, workId);
|
|
686
752
|
if (!version)
|
|
687
753
|
throw notFound("文件版本");
|
|
688
|
-
const
|
|
689
|
-
const volumes = Array.isArray(snapshot.volumes) ? snapshot.volumes : [];
|
|
754
|
+
const { volumes } = parseRestorableFileSnapshot(requiredString(version, "snapshot_json"), workId);
|
|
690
755
|
return this.db.transaction(() => {
|
|
691
756
|
const current = this.getWork(workId);
|
|
692
757
|
this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
|
|
@@ -707,20 +772,16 @@ export class Store {
|
|
|
707
772
|
this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
|
|
708
773
|
for (const volume of volumes) {
|
|
709
774
|
const volumeId = id("volume");
|
|
710
|
-
const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
|
|
711
775
|
this.insertVolumeWithId(workId, volumeId, {
|
|
712
|
-
title:
|
|
713
|
-
kind:
|
|
714
|
-
source:
|
|
715
|
-
description:
|
|
716
|
-
keywords:
|
|
717
|
-
sortOrder:
|
|
776
|
+
title: volume.title,
|
|
777
|
+
kind: volume.kind,
|
|
778
|
+
source: volume.source,
|
|
779
|
+
description: volume.description,
|
|
780
|
+
keywords: volume.keywords,
|
|
781
|
+
sortOrder: volume.sortOrder
|
|
718
782
|
}, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`);
|
|
719
|
-
for (const chapter of chapters) {
|
|
720
|
-
|
|
721
|
-
? String(chapter.chapterType)
|
|
722
|
-
: "正文");
|
|
723
|
-
this.insertChapter(workId, volumeId, String(chapter.title ?? "未命名章节"), String(chapter.content ?? ""), Number(chapter.sortOrder ?? 0), "restore", fileVersionId, chapterType);
|
|
783
|
+
for (const chapter of volume.chapters) {
|
|
784
|
+
this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.sortOrder, "restore", fileVersionId, chapter.chapterType);
|
|
724
785
|
}
|
|
725
786
|
}
|
|
726
787
|
this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
|
|
@@ -1193,15 +1254,19 @@ export class Store {
|
|
|
1193
1254
|
const membership = actor
|
|
1194
1255
|
? this.db.get("SELECT role, permissions_json FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
|
|
1195
1256
|
: undefined;
|
|
1196
|
-
const membershipPermissions = json(optionalString(membership ?? {}, "permissions_json"), {});
|
|
1197
1257
|
const membershipRole = String(membership?.role ?? "");
|
|
1258
|
+
const ownerAccess = ownerUserId === actor?.userId;
|
|
1259
|
+
const adminAccess = actor?.role === "admin" && actor.authentication !== "api-key";
|
|
1260
|
+
const modulePermissions = !actor || ownerAccess || adminAccess
|
|
1261
|
+
? fullWorkModulePermissions()
|
|
1262
|
+
: membershipRole
|
|
1263
|
+
? storedWorkModulePermissions(membershipRole, optionalString(membership ?? {}, "permissions_json"))
|
|
1264
|
+
: emptyWorkModulePermissions();
|
|
1198
1265
|
const accessRole = ownerUserId === actor?.userId
|
|
1199
1266
|
? "owner"
|
|
1200
|
-
:
|
|
1267
|
+
: adminAccess
|
|
1201
1268
|
? "admin"
|
|
1202
|
-
: membershipRole
|
|
1203
|
-
? "settings-editor"
|
|
1204
|
-
: ["editor", "viewer"].includes(membershipRole) ? membershipRole : null;
|
|
1269
|
+
: membershipRole ? classifyWorkModulePermissions(modulePermissions) : null;
|
|
1205
1270
|
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
1271
|
const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
|
|
1207
1272
|
return {
|
|
@@ -1217,8 +1282,9 @@ export class Store {
|
|
|
1217
1282
|
versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", requiredString(row, "id")),
|
|
1218
1283
|
ownerUserId,
|
|
1219
1284
|
accessRole,
|
|
1220
|
-
|
|
1221
|
-
|
|
1285
|
+
modulePermissions,
|
|
1286
|
+
chapterCount: modulePermissions.prose === "none" ? 0 : numberValue(count ?? {}, "chapter_count"),
|
|
1287
|
+
wordCount: modulePermissions.prose === "none" ? 0 : numberValue(count ?? {}, "word_count"),
|
|
1222
1288
|
createdAt: requiredString(row, "created_at"),
|
|
1223
1289
|
updatedAt: requiredString(row, "updated_at")
|
|
1224
1290
|
};
|