@qnote/q-ai-note 1.0.5 → 1.0.7

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.
Files changed (50) hide show
  1. package/README.md +41 -0
  2. package/dist/cli.js +55 -18
  3. package/dist/cli.js.map +1 -1
  4. package/dist/server/accessControl.d.ts +29 -0
  5. package/dist/server/accessControl.d.ts.map +1 -0
  6. package/dist/server/accessControl.js +161 -0
  7. package/dist/server/accessControl.js.map +1 -0
  8. package/dist/server/api/accessHelpers.d.ts +11 -0
  9. package/dist/server/api/accessHelpers.d.ts.map +1 -0
  10. package/dist/server/api/accessHelpers.js +45 -0
  11. package/dist/server/api/accessHelpers.js.map +1 -0
  12. package/dist/server/api/chat.d.ts.map +1 -1
  13. package/dist/server/api/chat.js +31 -0
  14. package/dist/server/api/chat.js.map +1 -1
  15. package/dist/server/api/diary.d.ts.map +1 -1
  16. package/dist/server/api/diary.js +61 -1
  17. package/dist/server/api/diary.js.map +1 -1
  18. package/dist/server/api/nodeEntities.d.ts.map +1 -1
  19. package/dist/server/api/nodeEntities.js +31 -0
  20. package/dist/server/api/nodeEntities.js.map +1 -1
  21. package/dist/server/api/projectSettings.d.ts +3 -0
  22. package/dist/server/api/projectSettings.d.ts.map +1 -0
  23. package/dist/server/api/projectSettings.js +29 -0
  24. package/dist/server/api/projectSettings.js.map +1 -0
  25. package/dist/server/api/sandbox.d.ts.map +1 -1
  26. package/dist/server/api/sandbox.js +35 -1
  27. package/dist/server/api/sandbox.js.map +1 -1
  28. package/dist/server/api/settings.d.ts.map +1 -1
  29. package/dist/server/api/settings.js +25 -1
  30. package/dist/server/api/settings.js.map +1 -1
  31. package/dist/server/api/workItem.d.ts.map +1 -1
  32. package/dist/server/api/workItem.js +59 -0
  33. package/dist/server/api/workItem.js.map +1 -1
  34. package/dist/server/config.d.ts +3 -2
  35. package/dist/server/config.d.ts.map +1 -1
  36. package/dist/server/config.js +6 -1
  37. package/dist/server/config.js.map +1 -1
  38. package/dist/server/index.d.ts +8 -2
  39. package/dist/server/index.d.ts.map +1 -1
  40. package/dist/server/index.js +102 -4
  41. package/dist/server/index.js.map +1 -1
  42. package/dist/server/projectConfig.d.ts +37 -0
  43. package/dist/server/projectConfig.d.ts.map +1 -0
  44. package/dist/server/projectConfig.js +180 -0
  45. package/dist/server/projectConfig.js.map +1 -0
  46. package/dist/web/app.js +760 -44
  47. package/dist/web/index.html +107 -60
  48. package/dist/web/styles.css +256 -11
  49. package/dist/web/vueRenderers.js +71 -57
  50. package/package.json +2 -2
package/dist/web/app.js CHANGED
@@ -9,6 +9,20 @@ const state = {
9
9
  operations: [],
10
10
  chats: [],
11
11
  settings: {},
12
+ readonly: false,
13
+ fullAccess: false,
14
+ canAccessSystemSettings: false,
15
+ pageAccess: {
16
+ sandboxes: true,
17
+ diaries: true,
18
+ changes: true,
19
+ settings: false,
20
+ },
21
+ sandboxAccessAll: 'read',
22
+ sandboxReadIds: [],
23
+ sandboxWriteIds: [],
24
+ currentSandboxWritable: false,
25
+ projectSettingsDraft: null,
12
26
  pendingAction: null,
13
27
  nodeEntities: [],
14
28
  nodeEntityStats: null,
@@ -16,7 +30,8 @@ const state = {
16
30
  nodeEntityFilter: 'all',
17
31
  editingNodeEntityId: null,
18
32
  nodeEntityFormExpanded: false,
19
- sandboxPresentationMode: false,
33
+ sandboxChatVisible: false,
34
+ sandboxChatVisibleBeforeFullscreen: false,
20
35
  sandboxFullscreenMode: false,
21
36
  workTreeViewMode: 'full',
22
37
  workItemElementPreviewMode: 'none',
@@ -38,6 +53,7 @@ let sandboxActionHandler = null;
38
53
  let sandboxEscLocked = false;
39
54
  let resizeRenderTimer = null;
40
55
  const WORK_ITEM_SHOW_ASSIGNEE_STORAGE_KEY = 'q-ai-note.work-item.show-assignee';
56
+ const WORK_TREE_VIEW_MODE_STORAGE_KEY = 'q-ai-note.work-tree.view-mode';
41
57
 
42
58
  function loadWorkItemAssigneePreference() {
43
59
  try {
@@ -60,14 +76,132 @@ function persistWorkItemAssigneePreference() {
60
76
  }
61
77
  }
62
78
 
63
- function applySandboxLayoutMode() {
79
+ function loadWorkTreeViewModePreference() {
80
+ try {
81
+ const raw = String(window.localStorage.getItem(WORK_TREE_VIEW_MODE_STORAGE_KEY) || '').trim();
82
+ if (raw === 'full' || raw === 'report' || raw === 'dense') {
83
+ state.workTreeViewMode = raw;
84
+ }
85
+ } catch {
86
+ // Ignore storage failures in restricted environments.
87
+ }
88
+ }
89
+
90
+ function persistWorkTreeViewModePreference() {
91
+ try {
92
+ window.localStorage.setItem(WORK_TREE_VIEW_MODE_STORAGE_KEY, state.workTreeViewMode || 'full');
93
+ } catch {
94
+ // Ignore storage failures in restricted environments.
95
+ }
96
+ }
97
+
98
+ function applySandboxChatVisibility() {
64
99
  const layout = document.getElementById('sandbox-layout');
65
- const toggleBtn = document.getElementById('toggle-sandbox-present-btn');
100
+ const toggleBtn = document.getElementById('toggle-sandbox-chat-btn');
66
101
  if (!layout || !toggleBtn) return;
67
- const isPresentation = Boolean(state.sandboxPresentationMode);
68
- layout.classList.toggle('presentation-mode', isPresentation);
69
- toggleBtn.textContent = isPresentation ? '退出展示态' : '展示态布局';
70
- toggleBtn.setAttribute('aria-pressed', isPresentation ? 'true' : 'false');
102
+ const chatAllowed = !state.readonly && state.currentSandboxWritable;
103
+ const shouldShow = chatAllowed && Boolean(state.sandboxChatVisible);
104
+ layout.classList.toggle('show-chat', shouldShow);
105
+ toggleBtn.classList.toggle('hidden', !chatAllowed);
106
+ toggleBtn.innerHTML = shouldShow
107
+ ? '<span class="icon" aria-hidden="true">🤖</span><span>隐藏 AI 助手</span>'
108
+ : '<span class="icon" aria-hidden="true">🤖</span><span>显示 AI 助手</span>';
109
+ toggleBtn.setAttribute('aria-pressed', shouldShow ? 'true' : 'false');
110
+ }
111
+
112
+ async function loadRuntimeMode() {
113
+ try {
114
+ const runtime = await apiRequest(`${API_BASE}/runtime`);
115
+ state.readonly = Boolean(runtime?.readonly);
116
+ state.fullAccess = Boolean(runtime?.full_access);
117
+ state.canAccessSystemSettings = Boolean(runtime?.can_access_system_settings);
118
+ state.pageAccess = {
119
+ sandboxes: Boolean(runtime?.page_access?.sandboxes),
120
+ diaries: Boolean(runtime?.page_access?.diaries),
121
+ changes: Boolean(runtime?.page_access?.changes),
122
+ settings: Boolean(runtime?.page_access?.settings),
123
+ };
124
+ state.sandboxAccessAll = String(runtime?.sandbox_access_all || 'none');
125
+ state.sandboxReadIds = Array.isArray(runtime?.sandbox_read_ids) ? runtime.sandbox_read_ids.map((id) => String(id)) : [];
126
+ state.sandboxWriteIds = Array.isArray(runtime?.sandbox_write_ids) ? runtime.sandbox_write_ids.map((id) => String(id)) : [];
127
+ state.currentSandboxWritable = !state.readonly && (state.fullAccess || state.sandboxAccessAll === 'write');
128
+ } catch {
129
+ state.readonly = false;
130
+ state.fullAccess = true;
131
+ state.canAccessSystemSettings = true;
132
+ state.pageAccess = { sandboxes: true, diaries: true, changes: true, settings: true };
133
+ state.sandboxAccessAll = 'write';
134
+ state.sandboxReadIds = [];
135
+ state.sandboxWriteIds = [];
136
+ state.currentSandboxWritable = true;
137
+ }
138
+ }
139
+
140
+ function canReadSandboxById(sandboxId) {
141
+ const id = String(sandboxId || '');
142
+ if (!id) return false;
143
+ if (state.fullAccess) return true;
144
+ if (state.sandboxAccessAll === 'read' || state.sandboxAccessAll === 'write') return true;
145
+ return state.sandboxReadIds.includes(id) || state.sandboxWriteIds.includes(id);
146
+ }
147
+
148
+ function canWriteSandboxById(sandboxId) {
149
+ const id = String(sandboxId || '');
150
+ if (!id) return false;
151
+ if (state.readonly) return false;
152
+ if (state.fullAccess) return true;
153
+ if (state.sandboxAccessAll === 'write') return true;
154
+ return state.sandboxWriteIds.includes(id);
155
+ }
156
+
157
+ function setHiddenById(id, hidden = true) {
158
+ const el = document.getElementById(id);
159
+ if (!el) return;
160
+ el.classList.toggle('hidden', hidden);
161
+ }
162
+
163
+ function applyReadonlyMode() {
164
+ document.body.classList.toggle('readonly-mode', state.readonly);
165
+ const sandboxesNav = document.querySelector('[data-nav="sandboxes"]');
166
+ if (sandboxesNav instanceof HTMLElement) {
167
+ sandboxesNav.classList.toggle('hidden', !state.pageAccess.sandboxes && !state.fullAccess);
168
+ }
169
+ const diariesNav = document.querySelector('[data-nav="diaries"]');
170
+ if (diariesNav instanceof HTMLElement) {
171
+ diariesNav.classList.toggle('hidden', !state.pageAccess.diaries && !state.fullAccess);
172
+ }
173
+ const changesNav = document.querySelector('[data-nav="changes"]');
174
+ if (changesNav instanceof HTMLElement) {
175
+ changesNav.classList.toggle('hidden', !state.pageAccess.changes && !state.fullAccess);
176
+ }
177
+ const projectSettingsNav = document.querySelector('[data-nav="settings"]');
178
+ if (projectSettingsNav instanceof HTMLElement) {
179
+ projectSettingsNav.classList.toggle('hidden', !state.pageAccess.settings);
180
+ }
181
+ const systemSettingsNav = document.querySelector('[data-nav="system-settings"]');
182
+ if (systemSettingsNav instanceof HTMLElement) {
183
+ systemSettingsNav.classList.toggle('hidden', !state.canAccessSystemSettings);
184
+ }
185
+ setHiddenById('page-settings', !state.pageAccess.settings);
186
+ setHiddenById('page-system-settings', !state.canAccessSystemSettings);
187
+ setHiddenById('add-sandbox-btn', state.readonly || !state.fullAccess);
188
+ setHiddenById('add-item-btn', state.readonly || !state.currentSandboxWritable);
189
+ setHiddenById('toggle-node-entity-form-btn', state.readonly || !state.currentSandboxWritable);
190
+ setHiddenById('drawer-diary-save-btn', state.readonly || !state.currentSandboxWritable);
191
+ setHiddenById('save-diary-btn', state.readonly);
192
+ const diaryForm = document.querySelector('#page-diaries .diary-form');
193
+ if (diaryForm instanceof HTMLElement) {
194
+ diaryForm.classList.toggle('hidden', state.readonly);
195
+ }
196
+ const drawerDiaryForm = document.querySelector('#node-entity-drawer .drawer-diary-quick-form');
197
+ if (drawerDiaryForm instanceof HTMLElement) {
198
+ drawerDiaryForm.classList.toggle('hidden', state.readonly || !state.currentSandboxWritable);
199
+ }
200
+ if (state.readonly) {
201
+ state.sandboxChatVisible = false;
202
+ closeQuickChatPopover();
203
+ }
204
+ applySandboxChatVisibility();
71
205
  }
72
206
 
73
207
  function getSandboxLayoutElement() {
@@ -100,7 +234,14 @@ function applySandboxFullscreenState() {
100
234
  const layout = getSandboxLayoutElement();
101
235
  const fullscreenRoot = getSandboxFullscreenElement();
102
236
  const toggleBtn = document.getElementById('toggle-sandbox-fullscreen-btn');
237
+ const wasFullscreen = Boolean(state.sandboxFullscreenMode);
103
238
  const isFullscreen = Boolean(fullscreenRoot && document.fullscreenElement === fullscreenRoot);
239
+ if (!wasFullscreen && isFullscreen) {
240
+ state.sandboxChatVisibleBeforeFullscreen = Boolean(state.sandboxChatVisible);
241
+ state.sandboxChatVisible = false;
242
+ } else if (wasFullscreen && !isFullscreen) {
243
+ state.sandboxChatVisible = Boolean(state.sandboxChatVisibleBeforeFullscreen);
244
+ }
104
245
  state.sandboxFullscreenMode = isFullscreen;
105
246
  if (layout) {
106
247
  layout.classList.toggle('is-fullscreen', isFullscreen);
@@ -112,9 +253,22 @@ function applySandboxFullscreenState() {
112
253
  toggleBtn.textContent = isFullscreen ? '退出全屏' : '全屏';
113
254
  toggleBtn.setAttribute('aria-pressed', isFullscreen ? 'true' : 'false');
114
255
  }
256
+ applySandboxChatVisibility();
257
+ applySandboxLayoutHeight();
115
258
  void syncSandboxEscLock(isFullscreen);
116
259
  }
117
260
 
261
+ function applySandboxLayoutHeight() {
262
+ const layout = getSandboxLayoutElement();
263
+ if (!(layout instanceof HTMLElement)) return;
264
+ const rect = layout.getBoundingClientRect();
265
+ const viewportHeight = Math.floor(window.visualViewport?.height || window.innerHeight || 0);
266
+ if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return;
267
+ const bottomGap = state.sandboxFullscreenMode ? 8 : 10;
268
+ const nextHeight = Math.max(320, viewportHeight - Math.floor(rect.top) - bottomGap);
269
+ layout.style.height = `${nextHeight}px`;
270
+ }
271
+
118
272
  async function toggleSandboxFullscreen() {
119
273
  const fullscreenRoot = getSandboxFullscreenElement();
120
274
  if (!fullscreenRoot) return;
@@ -215,6 +369,7 @@ function applyWorkItemElementPreviewMode() {
215
369
  function renderWorkTree() {
216
370
  const tree = document.getElementById('work-tree');
217
371
  if (!tree || !state.currentSandbox) return;
372
+ const treeReadonly = state.readonly || !state.currentSandboxWritable;
218
373
 
219
374
  const allItems = state.currentSandbox.items || [];
220
375
  const items = applyWorkItemFilters(allItems);
@@ -230,7 +385,7 @@ function renderWorkTree() {
230
385
  }
231
386
 
232
387
  if (allItems.length === 0) {
233
- tree.innerHTML = '<div class="empty-state"><p>点击上方"添加"按钮创建第一个任务</p></div>';
388
+ tree.innerHTML = `<div class="empty-state"><p>${treeReadonly ? '暂无任务' : '点击上方"添加"按钮创建第一个任务'}</p></div>`;
234
389
  return;
235
390
  }
236
391
 
@@ -246,7 +401,7 @@ function renderWorkTree() {
246
401
  }
247
402
  renderWorkTree();
248
403
  },
249
- onAddChild: (parentId) => {
404
+ onAddChild: treeReadonly ? undefined : (parentId) => {
250
405
  document.getElementById('item-dialog-title').textContent = '添加子任务';
251
406
  document.getElementById('item-dialog').dataset.editId = '';
252
407
  document.getElementById('new-item-name').value = '';
@@ -257,14 +412,14 @@ function renderWorkTree() {
257
412
  document.getElementById('new-item-parent').value = parentId;
258
413
  document.getElementById('item-dialog').showModal();
259
414
  },
260
- onAddDiary: (nodeId) => {
415
+ onAddDiary: treeReadonly ? undefined : (nodeId) => {
261
416
  showNodeEntityDrawer(nodeId, 'diary');
262
417
  const textarea = document.getElementById('drawer-diary-content');
263
418
  if (textarea instanceof HTMLTextAreaElement) {
264
419
  textarea.focus();
265
420
  }
266
421
  },
267
- onEdit: (id) => {
422
+ onEdit: treeReadonly ? undefined : (id) => {
268
423
  editWorkItem(id);
269
424
  },
270
425
  onSelect: (id) => {
@@ -273,7 +428,7 @@ function renderWorkTree() {
273
428
  onSelectEntity: (nodeId, entityType) => {
274
429
  showNodeEntityDrawer(nodeId, entityType || 'all');
275
430
  },
276
- onMoveNode: async (dragNodeId, newParentId) => {
431
+ onMoveNode: treeReadonly ? undefined : async (dragNodeId, newParentId) => {
277
432
  if (!state.currentSandbox) return;
278
433
  if (!dragNodeId || dragNodeId === newParentId) return;
279
434
  const byId = new Map((state.currentSandbox.items || []).map((item) => [item.id, item]));
@@ -301,7 +456,7 @@ function renderWorkTree() {
301
456
  });
302
457
  await loadSandbox(state.currentSandbox.id);
303
458
  },
304
- onReorderSiblings: async (dragNodeId, targetNodeId, position) => {
459
+ onReorderSiblings: treeReadonly ? undefined : async (dragNodeId, targetNodeId, position) => {
305
460
  if (!state.currentSandbox) return;
306
461
  if (!dragNodeId || !targetNodeId || dragNodeId === targetNodeId) return;
307
462
  const byId = new Map((state.currentSandbox.items || []).map((item) => [item.id, item]));
@@ -334,7 +489,7 @@ function renderWorkTree() {
334
489
  });
335
490
  await loadSandbox(state.currentSandbox.id);
336
491
  },
337
- onReorderLanes: async (dragRootId, targetRootId) => {
492
+ onReorderLanes: treeReadonly ? undefined : async (dragRootId, targetRootId) => {
338
493
  if (!state.currentSandbox) return;
339
494
  if (!dragRootId || !targetRootId || dragRootId === targetRootId) return;
340
495
  const roots = (state.currentSandbox.items || []).filter((item) => !item.parent_id);
@@ -359,19 +514,21 @@ function renderWorkTree() {
359
514
  });
360
515
  await loadSandbox(state.currentSandbox.id);
361
516
  },
362
- onDelete: async (id) => {
517
+ onDelete: treeReadonly ? undefined : async (id) => {
363
518
  if (confirm('确定删除此任务?')) {
364
519
  await apiRequest(`${API_BASE}/items/${id}`, { method: 'DELETE' });
365
520
  await loadSandbox(state.currentSandbox.id);
366
521
  }
367
522
  },
368
- onQuickChat: (id, el) => {
523
+ onQuickChat: treeReadonly ? undefined : (id, el) => {
369
524
  openQuickChatPopover(id, el);
370
525
  },
371
526
  renderMode: state.workTreeViewMode === 'dense' ? 'dense' : 'card',
372
527
  showAssignee: state.workItemShowAssignee,
373
528
  elementPreviewMode: state.workItemElementPreviewMode,
374
529
  entityRowsByNodeId,
530
+ readonly: treeReadonly,
531
+ selectedId: state.selectedNodeId || '',
375
532
  });
376
533
 
377
534
  populateParentSelect(allItems);
@@ -481,12 +638,17 @@ function compareSiblingOrder(a, b, keyName = 'order_key') {
481
638
  return String(a?.created_at || '').localeCompare(String(b?.created_at || ''));
482
639
  }
483
640
 
484
- function populateParentSelect(items) {
641
+ function populateParentSelect(items, preferredParentId = null) {
485
642
  const select = document.getElementById('new-item-parent');
486
643
  if (!select) return;
487
-
644
+ const expectedValue = preferredParentId === null || preferredParentId === undefined
645
+ ? String(select.value || '')
646
+ : String(preferredParentId || '');
647
+
488
648
  select.innerHTML = '<option value="">无(顶级)</option>' +
489
649
  items.map(i => `<option value="${i.id}">${safeText(i.name)}</option>`).join('');
650
+ const hasExpectedValue = Array.from(select.options).some((option) => option.value === expectedValue);
651
+ select.value = hasExpectedValue ? expectedValue : '';
490
652
  }
491
653
 
492
654
  function renderMarkdownSnippet(text) {
@@ -674,6 +836,7 @@ function closeNodeEntityDrawer() {
674
836
  state.nodeEntityFilter = 'all';
675
837
  state.editingNodeEntityId = null;
676
838
  state.nodeEntityFormExpanded = false;
839
+ renderWorkTree();
677
840
  }
678
841
 
679
842
  function setNodeEntityFormExpanded(expanded) {
@@ -755,13 +918,15 @@ function renderNodeEntityList(nodeId) {
755
918
  <span class="entity-type-pill">diary</span>
756
919
  <strong>${safeText('日志记录')}</strong>
757
920
  </div>
758
- <div class="entity-card-actions">
759
- <button class="btn btn-secondary btn-sm" data-diary-edit-id="${safeText(row.id)}">编辑</button>
760
- ${row.processed ? '' : `
761
- <button class="btn btn-secondary btn-sm" data-diary-confirm-id="${safeText(row.id)}">采纳</button>
762
- <button class="btn btn-secondary btn-sm" data-diary-ignore-id="${safeText(row.id)}">忽略</button>
763
- `}
764
- </div>
921
+ ${state.readonly ? '' : `
922
+ <div class="entity-card-actions">
923
+ <button class="btn btn-secondary btn-sm" data-diary-edit-id="${safeText(row.id)}">编辑</button>
924
+ ${row.processed ? '' : `
925
+ <button class="btn btn-secondary btn-sm" data-diary-confirm-id="${safeText(row.id)}">采纳</button>
926
+ <button class="btn btn-secondary btn-sm" data-diary-ignore-id="${safeText(row.id)}">忽略</button>
927
+ `}
928
+ </div>
929
+ `}
765
930
  </div>
766
931
  <div class="entity-meta">
767
932
  ${safeText(new Date(row.created_at).toLocaleString())}
@@ -778,10 +943,12 @@ function renderNodeEntityList(nodeId) {
778
943
  <span class="entity-type-pill">${safeText(row.entity_type)}</span>
779
944
  <strong>${safeText(row.title || '-')}</strong>
780
945
  </div>
781
- <div class="entity-card-actions">
782
- <button class="btn btn-secondary btn-sm" data-entity-edit-id="${safeText(row.id)}">编辑</button>
783
- <button class="btn btn-secondary btn-sm" data-entity-delete-id="${safeText(row.id)}">删除</button>
784
- </div>
946
+ ${state.readonly ? '' : `
947
+ <div class="entity-card-actions">
948
+ <button class="btn btn-secondary btn-sm" data-entity-edit-id="${safeText(row.id)}">编辑</button>
949
+ <button class="btn btn-secondary btn-sm" data-entity-delete-id="${safeText(row.id)}">删除</button>
950
+ </div>
951
+ `}
785
952
  </div>
786
953
  <div class="entity-meta">
787
954
  ${safeText(new Date(row.created_at).toLocaleString())}
@@ -920,6 +1087,7 @@ function showNodeEntityDrawer(nodeId, preferredFilter = 'all') {
920
1087
  state.nodeEntityFilter = filter;
921
1088
  title.textContent = node.name || nodeId;
922
1089
  renderNodeEntitySummary(nodeId);
1090
+ renderWorkTree();
923
1091
  renderQuickDiaryTargetLabel();
924
1092
  renderNodeEntityFilterTabs();
925
1093
  renderNodeEntityList(nodeId);
@@ -1031,6 +1199,7 @@ function composeQuickChatContent(nodeId, userQuestion) {
1031
1199
  }
1032
1200
 
1033
1201
  async function sendSandboxChatMessage(content, options = {}) {
1202
+ if (state.readonly) return;
1034
1203
  if (!content || !state.currentSandbox) return;
1035
1204
  const messages = document.getElementById('sandbox-chat-messages');
1036
1205
  const btn = document.getElementById('sandbox-send-btn');
@@ -1189,6 +1358,13 @@ function openQuickChatPopover(nodeId, anchorEl) {
1189
1358
  }
1190
1359
 
1191
1360
  async function loadSandboxes() {
1361
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
1362
+ state.sandboxes = [];
1363
+ renderSandboxes();
1364
+ renderSandboxesSummary();
1365
+ updateSandboxSelect();
1366
+ return;
1367
+ }
1192
1368
  state.sandboxes = await apiRequest(`${API_BASE}/sandboxes`);
1193
1369
  renderSandboxes();
1194
1370
  renderSandboxesSummary();
@@ -1221,17 +1397,19 @@ function renderSandboxes() {
1221
1397
  onOpen: (id) => {
1222
1398
  window.location.hash = `/sandbox/${id}`;
1223
1399
  },
1224
- onDelete: async (id) => {
1400
+ onDelete: (state.readonly || !state.fullAccess) ? undefined : async (id) => {
1225
1401
  if (confirm('确定删除此沙盘?')) {
1226
1402
  await apiRequest(`${API_BASE}/sandboxes/${id}`, { method: 'DELETE' });
1227
1403
  await loadSandboxes();
1228
1404
  }
1229
- }
1405
+ },
1406
+ readonly: state.readonly || !state.fullAccess,
1230
1407
  });
1231
1408
  }
1232
1409
 
1233
1410
  async function loadSandbox(id) {
1234
1411
  closeQuickChatPopover();
1412
+ state.currentSandboxWritable = canWriteSandboxById(id);
1235
1413
  const [sandbox, items, diaries, entities, entityStats] = await Promise.all([
1236
1414
  apiRequest(`${API_BASE}/sandboxes/${id}`),
1237
1415
  apiRequest(`${API_BASE}/sandboxes/${id}/items`),
@@ -1247,12 +1425,20 @@ async function loadSandbox(id) {
1247
1425
  applyWorkTreeViewMode(state.workTreeViewMode || 'full');
1248
1426
  applyWorkItemAssigneeToggle();
1249
1427
  applyWorkItemElementPreviewMode();
1250
- applySandboxLayoutMode();
1428
+ applySandboxChatVisibility();
1251
1429
  applySandboxFullscreenState();
1252
1430
  renderQuickDiaryTargetLabel();
1253
1431
  renderSandboxOverview();
1254
1432
  renderWorkTree();
1255
- loadSandboxChats(id);
1433
+ applySandboxLayoutHeight();
1434
+ applyReadonlyMode();
1435
+ if (state.readonly || !state.currentSandboxWritable) {
1436
+ state.chats = [];
1437
+ const messages = document.getElementById('sandbox-chat-messages');
1438
+ if (messages) messages.innerHTML = '';
1439
+ } else {
1440
+ loadSandboxChats(id);
1441
+ }
1256
1442
  }
1257
1443
 
1258
1444
  function renderSandboxOverview() {
@@ -1277,6 +1463,7 @@ function renderSandboxOverview() {
1277
1463
  }
1278
1464
 
1279
1465
  async function loadSandboxChats(sandboxId) {
1466
+ if (state.readonly) return;
1280
1467
  const messages = document.getElementById('sandbox-chat-messages');
1281
1468
  if (!messages) return;
1282
1469
 
@@ -1436,25 +1623,26 @@ function renderDiaries() {
1436
1623
  openDiaryWorkItemInSandbox(sandboxId, workItemId);
1437
1624
  },
1438
1625
  renderContent: (content) => renderMarkdownSnippet(content),
1439
- onConfirm: async (id) => {
1626
+ onConfirm: state.readonly ? undefined : async (id) => {
1440
1627
  await apiRequest(`${API_BASE}/diaries/${id}/process`, {
1441
1628
  method: 'PUT',
1442
1629
  body: JSON.stringify({ action: 'confirm' }),
1443
1630
  });
1444
1631
  await loadDiaries();
1445
1632
  },
1446
- onIgnore: async (id) => {
1633
+ onIgnore: state.readonly ? undefined : async (id) => {
1447
1634
  await apiRequest(`${API_BASE}/diaries/${id}/process`, {
1448
1635
  method: 'PUT',
1449
1636
  body: JSON.stringify({ action: 'ignore' }),
1450
1637
  });
1451
1638
  await loadDiaries();
1452
1639
  },
1453
- onEdit: async (id) => {
1640
+ onEdit: state.readonly ? undefined : async (id) => {
1454
1641
  const diary = state.diaries.find((row) => row.id === id);
1455
1642
  if (!diary) return;
1456
1643
  openDiaryEditDialog(diary);
1457
1644
  },
1645
+ readonly: state.readonly,
1458
1646
  });
1459
1647
  }
1460
1648
 
@@ -1741,6 +1929,252 @@ async function loadSettings() {
1741
1929
  apiKeyInput.placeholder = state.settings.has_api_key ? '已配置,留空表示不修改' : 'sk-...';
1742
1930
  document.getElementById('setting-model').value = state.settings.model || '';
1743
1931
  renderSettingHeadersRows(state.settings.headers || {});
1932
+ const editableIpInput = document.getElementById('setting-editable-ips');
1933
+ if (editableIpInput instanceof HTMLTextAreaElement) {
1934
+ editableIpInput.value = Array.isArray(state.settings.editable_ip_allowlist)
1935
+ ? state.settings.editable_ip_allowlist.join('\n')
1936
+ : '';
1937
+ }
1938
+ }
1939
+
1940
+ function normalizeProjectSettingsDraft(config) {
1941
+ const users = Array.isArray(config?.users) ? config.users : [];
1942
+ const profiles = Array.isArray(config?.profiles) ? config.profiles : [];
1943
+ const bindings = Array.isArray(config?.bindings) ? config.bindings : [];
1944
+ return {
1945
+ users: users.map((user, idx) => ({
1946
+ id: String(user?.id || `user-${idx + 1}`).trim(),
1947
+ name: String(user?.name || '').trim(),
1948
+ ips: Array.isArray(user?.ips) ? user.ips.map((ip) => String(ip || '').trim()).filter((ip) => Boolean(ip)) : [],
1949
+ })),
1950
+ profiles: profiles.map((profile, idx) => ({
1951
+ id: String(profile?.id || `profile-${idx + 1}`).trim(),
1952
+ name: String(profile?.name || '').trim(),
1953
+ apply_to_all_users: Boolean(profile?.apply_to_all_users),
1954
+ page_access: {
1955
+ sandboxes: Boolean(profile?.page_access?.sandboxes),
1956
+ diaries: Boolean(profile?.page_access?.diaries),
1957
+ changes: Boolean(profile?.page_access?.changes),
1958
+ settings: Boolean(profile?.page_access?.settings),
1959
+ },
1960
+ sandbox_access: {
1961
+ all: String(profile?.sandbox_access?.all || 'none'),
1962
+ items: Array.isArray(profile?.sandbox_access?.items)
1963
+ ? profile.sandbox_access.items.map((item) => ({
1964
+ sandbox_id: String(item?.sandbox_id || '').trim(),
1965
+ access: String(item?.access || 'read'),
1966
+ }))
1967
+ : [],
1968
+ },
1969
+ })),
1970
+ bindings: bindings.map((binding) => ({
1971
+ user_id: String(binding?.user_id || '').trim(),
1972
+ profile_ids: Array.isArray(binding?.profile_ids)
1973
+ ? binding.profile_ids.map((id) => String(id || '').trim()).filter((id) => Boolean(id))
1974
+ : [],
1975
+ })),
1976
+ };
1977
+ }
1978
+
1979
+ function renderProjectUsersEditor() {
1980
+ const container = document.getElementById('project-users-list');
1981
+ if (!(container instanceof HTMLElement)) return;
1982
+ const users = state.projectSettingsDraft?.users || [];
1983
+ if (!users.length) {
1984
+ container.innerHTML = '<div class="project-empty">暂无用户,点击上方“添加用户”。</div>';
1985
+ return;
1986
+ }
1987
+ container.innerHTML = users.map((user, idx) => `
1988
+ <div class="project-row">
1989
+ <input type="text" data-role="project-user-id" data-idx="${idx}" placeholder="用户 ID(唯一)" value="${escapeHtml(user.id)}">
1990
+ <input type="text" data-role="project-user-name" data-idx="${idx}" placeholder="显示名称" value="${escapeHtml(user.name)}">
1991
+ <div class="project-ip-editor">
1992
+ <div class="project-ip-chips">
1993
+ ${(user.ips || []).map((ip, ipIdx) => `
1994
+ <span class="project-ip-chip">
1995
+ <span>${escapeHtml(ip)}</span>
1996
+ <button class="project-ip-remove" type="button" title="删除 IP" data-role="remove-project-user-ip" data-idx="${idx}" data-ip-idx="${ipIdx}">×</button>
1997
+ </span>
1998
+ `).join('')}
1999
+ </div>
2000
+ <div class="project-ip-input-row">
2001
+ <input type="text" data-role="project-user-ip-input" data-idx="${idx}" placeholder="输入 IP 后回车或点击添加">
2002
+ <button class="btn btn-secondary btn-sm" data-role="add-project-user-ip" data-idx="${idx}" type="button">添加 IP</button>
2003
+ </div>
2004
+ </div>
2005
+ <button class="btn btn-secondary btn-sm" data-role="remove-project-user" data-idx="${idx}" type="button">删除</button>
2006
+ </div>
2007
+ `).join('');
2008
+ }
2009
+
2010
+ function addUserIpAtIndex(userIdx, rawIp) {
2011
+ if (!state.projectSettingsDraft) return;
2012
+ const idx = Number(userIdx);
2013
+ if (!Number.isFinite(idx) || idx < 0) return;
2014
+ const user = state.projectSettingsDraft.users[idx];
2015
+ if (!user) return;
2016
+ const ip = String(rawIp || '').trim();
2017
+ if (!ip) return;
2018
+ if (ip.includes('/')) {
2019
+ alert('仅支持精确 IP,不支持 CIDR。');
2020
+ return;
2021
+ }
2022
+ if ((user.ips || []).includes(ip)) return;
2023
+ user.ips = [...(user.ips || []), ip];
2024
+ renderProjectSettingsEditor();
2025
+ }
2026
+
2027
+ function renderProjectProfilesEditor() {
2028
+ const container = document.getElementById('project-profiles-list');
2029
+ if (!(container instanceof HTMLElement)) return;
2030
+ const profiles = state.projectSettingsDraft?.profiles || [];
2031
+ const sandboxOptions = (state.sandboxes || []).map((sandbox) => ({
2032
+ id: String(sandbox.id || ''),
2033
+ name: String(sandbox.name || ''),
2034
+ }));
2035
+ if (!profiles.length) {
2036
+ container.innerHTML = '<div class="project-empty">暂无 Profile,点击上方“添加 Profile”。</div>';
2037
+ return;
2038
+ }
2039
+ container.innerHTML = profiles.map((profile, idx) => `
2040
+ <div class="project-profile-card">
2041
+ <div class="project-profile-grid">
2042
+ <input type="text" data-role="project-profile-id" data-idx="${idx}" placeholder="Profile ID(唯一)" value="${escapeHtml(profile.id)}">
2043
+ <input type="text" data-role="project-profile-name" data-idx="${idx}" placeholder="Profile 名称" value="${escapeHtml(profile.name)}">
2044
+ <select data-role="project-profile-all-access" data-idx="${idx}">
2045
+ <option value="none" ${profile.sandbox_access.all === 'none' ? 'selected' : ''}>默认沙盘权限:none</option>
2046
+ <option value="read" ${profile.sandbox_access.all === 'read' ? 'selected' : ''}>默认沙盘权限:read</option>
2047
+ <option value="write" ${profile.sandbox_access.all === 'write' ? 'selected' : ''}>默认沙盘权限:write</option>
2048
+ </select>
2049
+ <label class="inline-checkbox">
2050
+ <input type="checkbox" data-role="project-profile-apply-all" data-idx="${idx}" ${profile.apply_to_all_users ? 'checked' : ''}>
2051
+ <span>apply_to_all_users</span>
2052
+ </label>
2053
+ </div>
2054
+ <div class="project-profile-pages">
2055
+ <label class="inline-checkbox"><input type="checkbox" data-role="project-profile-page" data-idx="${idx}" data-page="sandboxes" ${profile.page_access.sandboxes ? 'checked' : ''}><span>沙盘</span></label>
2056
+ <label class="inline-checkbox"><input type="checkbox" data-role="project-profile-page" data-idx="${idx}" data-page="diaries" ${profile.page_access.diaries ? 'checked' : ''}><span>日记</span></label>
2057
+ <label class="inline-checkbox"><input type="checkbox" data-role="project-profile-page" data-idx="${idx}" data-page="changes" ${profile.page_access.changes ? 'checked' : ''}><span>变化</span></label>
2058
+ <label class="inline-checkbox"><input type="checkbox" data-role="project-profile-page" data-idx="${idx}" data-page="settings" ${profile.page_access.settings ? 'checked' : ''}><span>项目设置</span></label>
2059
+ </div>
2060
+ <div class="project-sandbox-items">
2061
+ ${(profile.sandbox_access.items || []).map((item, itemIdx) => `
2062
+ <div class="project-row compact">
2063
+ <select data-role="project-profile-sandbox-id" data-idx="${idx}" data-item-idx="${itemIdx}">
2064
+ <option value="">选择沙盘</option>
2065
+ ${sandboxOptions.map((sandbox) => `
2066
+ <option value="${escapeHtml(sandbox.id)}" ${item.sandbox_id === sandbox.id ? 'selected' : ''}>
2067
+ ${escapeHtml(sandbox.name || sandbox.id)} (${escapeHtml(sandbox.id)})
2068
+ </option>
2069
+ `).join('')}
2070
+ ${item.sandbox_id && !sandboxOptions.some((sandbox) => sandbox.id === item.sandbox_id) ? `
2071
+ <option value="${escapeHtml(item.sandbox_id)}" selected>
2072
+ 已不存在的沙盘 (${escapeHtml(item.sandbox_id)})
2073
+ </option>
2074
+ ` : ''}
2075
+ </select>
2076
+ <select data-role="project-profile-sandbox-access" data-idx="${idx}" data-item-idx="${itemIdx}">
2077
+ <option value="none" ${item.access === 'none' ? 'selected' : ''}>none</option>
2078
+ <option value="read" ${item.access === 'read' ? 'selected' : ''}>read</option>
2079
+ <option value="write" ${item.access === 'write' ? 'selected' : ''}>write</option>
2080
+ </select>
2081
+ <button class="btn btn-secondary btn-sm" data-role="remove-project-profile-sandbox-item" data-idx="${idx}" data-item-idx="${itemIdx}" type="button">删除</button>
2082
+ </div>
2083
+ `).join('')}
2084
+ <button class="btn btn-secondary btn-sm" data-role="add-project-profile-sandbox-item" data-idx="${idx}" type="button">+ 添加沙盘白名单规则</button>
2085
+ </div>
2086
+ <div class="project-profile-footer">
2087
+ <button class="btn btn-secondary btn-sm" data-role="remove-project-profile" data-idx="${idx}" type="button">删除 Profile</button>
2088
+ </div>
2089
+ </div>
2090
+ `).join('');
2091
+ }
2092
+
2093
+ function renderProjectBindingsEditor() {
2094
+ const container = document.getElementById('project-bindings-list');
2095
+ if (!(container instanceof HTMLElement)) return;
2096
+ const bindings = state.projectSettingsDraft?.bindings || [];
2097
+ const users = state.projectSettingsDraft?.users || [];
2098
+ const profiles = state.projectSettingsDraft?.profiles || [];
2099
+ if (!bindings.length) {
2100
+ container.innerHTML = '<div class="project-empty">暂无绑定,点击上方“添加绑定”。</div>';
2101
+ return;
2102
+ }
2103
+ container.innerHTML = bindings.map((binding, idx) => `
2104
+ <div class="project-binding-card">
2105
+ <div class="project-row compact">
2106
+ <select data-role="project-binding-user" data-idx="${idx}">
2107
+ <option value="">选择用户</option>
2108
+ ${users.map((user) => `<option value="${escapeHtml(user.id)}" ${binding.user_id === user.id ? 'selected' : ''}>${escapeHtml(user.name || user.id)} (${escapeHtml(user.id)})</option>`).join('')}
2109
+ </select>
2110
+ <button class="btn btn-secondary btn-sm" data-role="remove-project-binding" data-idx="${idx}" type="button">删除</button>
2111
+ </div>
2112
+ <div class="project-binding-profiles">
2113
+ ${profiles.map((profile) => `
2114
+ <label class="inline-checkbox">
2115
+ <input type="checkbox" data-role="project-binding-profile" data-idx="${idx}" data-profile-id="${escapeHtml(profile.id)}" ${binding.profile_ids.includes(profile.id) ? 'checked' : ''}>
2116
+ <span>${escapeHtml(profile.name || profile.id)}</span>
2117
+ </label>
2118
+ `).join('')}
2119
+ </div>
2120
+ </div>
2121
+ `).join('');
2122
+ }
2123
+
2124
+ function renderProjectSettingsEditor() {
2125
+ renderProjectUsersEditor();
2126
+ renderProjectProfilesEditor();
2127
+ renderProjectBindingsEditor();
2128
+ }
2129
+
2130
+ async function loadProjectSettings() {
2131
+ if (state.fullAccess || state.pageAccess.sandboxes) {
2132
+ try {
2133
+ await loadSandboxes();
2134
+ } catch {
2135
+ // Ignore sandbox list failures; project settings can still be edited.
2136
+ }
2137
+ }
2138
+ const config = await apiRequest(`${API_BASE}/project-settings`);
2139
+ state.projectSettingsDraft = normalizeProjectSettingsDraft(config);
2140
+ renderProjectSettingsEditor();
2141
+ }
2142
+
2143
+ function collectProjectSettingsPayload() {
2144
+ const draft = state.projectSettingsDraft || { users: [], profiles: [], bindings: [] };
2145
+ return {
2146
+ users: draft.users.map((user) => ({
2147
+ id: String(user.id || '').trim(),
2148
+ name: String(user.name || '').trim(),
2149
+ ips: Array.from(new Set((user.ips || []).map((ip) => String(ip || '').trim()).filter((ip) => Boolean(ip)))),
2150
+ })),
2151
+ profiles: draft.profiles.map((profile) => ({
2152
+ id: String(profile.id || '').trim(),
2153
+ name: String(profile.name || '').trim(),
2154
+ apply_to_all_users: Boolean(profile.apply_to_all_users),
2155
+ page_access: {
2156
+ sandboxes: Boolean(profile.page_access?.sandboxes),
2157
+ diaries: Boolean(profile.page_access?.diaries),
2158
+ changes: Boolean(profile.page_access?.changes),
2159
+ settings: Boolean(profile.page_access?.settings),
2160
+ },
2161
+ sandbox_access: {
2162
+ all: String(profile.sandbox_access?.all || 'none'),
2163
+ items: (profile.sandbox_access?.items || [])
2164
+ .map((item) => ({
2165
+ sandbox_id: String(item.sandbox_id || '').trim(),
2166
+ access: String(item.access || 'none'),
2167
+ }))
2168
+ .filter((item) => Boolean(item.sandbox_id)),
2169
+ },
2170
+ })),
2171
+ bindings: draft.bindings
2172
+ .map((binding) => ({
2173
+ user_id: String(binding.user_id || '').trim(),
2174
+ profile_ids: Array.from(new Set((binding.profile_ids || []).map((id) => String(id || '').trim()).filter((id) => Boolean(id)))),
2175
+ }))
2176
+ .filter((binding) => Boolean(binding.user_id)),
2177
+ };
1744
2178
  }
1745
2179
 
1746
2180
  function createSettingHeaderRow(key = '', value = '') {
@@ -1807,15 +2241,18 @@ function editWorkItem(id) {
1807
2241
  document.getElementById('new-item-assignee').value = item.assignee;
1808
2242
  document.getElementById('new-item-status').value = item.status;
1809
2243
  document.getElementById('new-item-priority').value = item.priority;
2244
+ document.getElementById('new-item-parent').value = String(item.parent_id || '');
1810
2245
 
1811
2246
  document.getElementById('item-dialog').dataset.editId = id;
1812
2247
  document.getElementById('item-dialog').showModal();
1813
2248
  }
1814
2249
 
1815
- function initApp() {
1816
- const hash = window.location.hash;
2250
+ async function initApp() {
2251
+ await loadRuntimeMode();
1817
2252
  loadWorkItemAssigneePreference();
2253
+ loadWorkTreeViewModePreference();
1818
2254
  renderQuickDiaryTargetLabel();
2255
+ applyReadonlyMode();
1819
2256
 
1820
2257
  document.querySelectorAll('.nav-list a').forEach(link => {
1821
2258
  link.addEventListener('click', (e) => {
@@ -1825,6 +2262,7 @@ function initApp() {
1825
2262
  });
1826
2263
 
1827
2264
  document.getElementById('add-sandbox-btn')?.addEventListener('click', () => {
2265
+ if (state.readonly) return;
1828
2266
  document.getElementById('sandbox-dialog').showModal();
1829
2267
  });
1830
2268
 
@@ -1833,6 +2271,7 @@ function initApp() {
1833
2271
  });
1834
2272
 
1835
2273
  const createSandbox = async (e) => {
2274
+ if (state.readonly) return;
1836
2275
  e?.preventDefault?.();
1837
2276
  const name = document.getElementById('new-sandbox-name').value;
1838
2277
  const description = document.getElementById('new-sandbox-desc').value;
@@ -1856,6 +2295,7 @@ function initApp() {
1856
2295
  document.getElementById('confirm-sandbox-btn')?.addEventListener('click', createSandbox);
1857
2296
 
1858
2297
  document.getElementById('add-item-btn')?.addEventListener('click', () => {
2298
+ if (state.readonly) return;
1859
2299
  document.getElementById('item-dialog-title').textContent = '添加任务';
1860
2300
  document.getElementById('item-dialog').dataset.editId = '';
1861
2301
  document.getElementById('new-item-name').value = '';
@@ -1867,9 +2307,11 @@ function initApp() {
1867
2307
  document.getElementById('item-dialog').showModal();
1868
2308
  });
1869
2309
 
1870
- document.getElementById('toggle-sandbox-present-btn')?.addEventListener('click', () => {
1871
- state.sandboxPresentationMode = !state.sandboxPresentationMode;
1872
- applySandboxLayoutMode();
2310
+ document.getElementById('toggle-sandbox-chat-btn')?.addEventListener('click', () => {
2311
+ if (state.readonly) return;
2312
+ state.sandboxChatVisible = !state.sandboxChatVisible;
2313
+ applySandboxChatVisibility();
2314
+ applySandboxLayoutHeight();
1873
2315
  });
1874
2316
 
1875
2317
  document.getElementById('toggle-sandbox-fullscreen-btn')?.addEventListener('click', async () => {
@@ -1890,6 +2332,7 @@ function initApp() {
1890
2332
  });
1891
2333
 
1892
2334
  document.getElementById('toggle-node-entity-form-btn')?.addEventListener('click', () => {
2335
+ if (state.readonly) return;
1893
2336
  setNodeEntityFormExpanded(!state.nodeEntityFormExpanded);
1894
2337
  if (state.nodeEntityFormExpanded) {
1895
2338
  document.getElementById('entity-title-input')?.focus();
@@ -1918,6 +2361,7 @@ function initApp() {
1918
2361
  });
1919
2362
 
1920
2363
  document.getElementById('create-node-entity-btn')?.addEventListener('click', async () => {
2364
+ if (state.readonly) return;
1921
2365
  if (!state.currentSandbox || !state.selectedNodeId) return;
1922
2366
  const btn = document.getElementById('create-node-entity-btn');
1923
2367
  const title = document.getElementById('entity-title-input').value.trim();
@@ -2016,6 +2460,7 @@ function initApp() {
2016
2460
  });
2017
2461
 
2018
2462
  document.getElementById('confirm-item-btn')?.addEventListener('click', async () => {
2463
+ if (state.readonly) return;
2019
2464
  const dialog = document.getElementById('item-dialog');
2020
2465
  const editId = dialog.dataset.editId || null;
2021
2466
  const isNewItem = !editId;
@@ -2062,6 +2507,7 @@ function initApp() {
2062
2507
  });
2063
2508
 
2064
2509
  document.getElementById('rollback-btn')?.addEventListener('click', async () => {
2510
+ if (state.readonly) return;
2065
2511
  if (!state.currentSandbox?.items?.length) return;
2066
2512
  const lastItem = state.currentSandbox.items[state.currentSandbox.items.length - 1];
2067
2513
  await apiRequest(`${API_BASE}/items/${lastItem.id}/rollback`, { method: 'POST' });
@@ -2069,6 +2515,7 @@ function initApp() {
2069
2515
  });
2070
2516
 
2071
2517
  document.getElementById('sandbox-send-btn')?.addEventListener('click', async () => {
2518
+ if (state.readonly) return;
2072
2519
  const input = document.getElementById('sandbox-chat-input');
2073
2520
  const content = input.value.trim();
2074
2521
  if (!content || !state.currentSandbox) return;
@@ -2186,12 +2633,14 @@ function initApp() {
2186
2633
  sandboxActionHandler = handleAIAction;
2187
2634
 
2188
2635
  document.getElementById('sandbox-chat-input')?.addEventListener('keypress', (e) => {
2636
+ if (state.readonly) return;
2189
2637
  if (e.key === 'Enter') {
2190
2638
  document.getElementById('sandbox-send-btn').click();
2191
2639
  }
2192
2640
  });
2193
2641
 
2194
2642
  document.getElementById('save-diary-btn')?.addEventListener('click', async () => {
2643
+ if (state.readonly) return;
2195
2644
  const content = document.getElementById('diary-content').value.trim();
2196
2645
  if (!content) return;
2197
2646
  await saveDiaryEntry({ content, sandboxId: null, workItemId: null });
@@ -2200,6 +2649,7 @@ function initApp() {
2200
2649
  });
2201
2650
 
2202
2651
  document.getElementById('drawer-diary-save-btn')?.addEventListener('click', async () => {
2652
+ if (state.readonly) return;
2203
2653
  if (!state.currentSandbox || !state.selectedNodeId) return;
2204
2654
  const textarea = document.getElementById('drawer-diary-content');
2205
2655
  if (!(textarea instanceof HTMLTextAreaElement)) return;
@@ -2222,9 +2672,11 @@ function initApp() {
2222
2672
  closeDiaryEditDialog();
2223
2673
  });
2224
2674
  document.getElementById('confirm-edit-diary-btn')?.addEventListener('click', async () => {
2675
+ if (state.readonly) return;
2225
2676
  await submitDiaryEditDialog();
2226
2677
  });
2227
2678
  document.getElementById('diary-edit-content')?.addEventListener('keydown', async (event) => {
2679
+ if (state.readonly) return;
2228
2680
  const isSubmit = event.key === 'Enter' && (event.metaKey || event.ctrlKey);
2229
2681
  if (!isSubmit) return;
2230
2682
  event.preventDefault();
@@ -2241,10 +2693,13 @@ function initApp() {
2241
2693
  });
2242
2694
 
2243
2695
  document.getElementById('save-settings-btn')?.addEventListener('click', async () => {
2696
+ if (state.readonly || !state.canAccessSystemSettings) return;
2244
2697
  const api_url = document.getElementById('setting-api-url').value;
2245
2698
  const api_key = document.getElementById('setting-api-key').value;
2246
2699
  const model = document.getElementById('setting-model').value;
2247
2700
  const headers = collectSettingHeadersFromUI();
2701
+ const editableIpsRaw = document.getElementById('setting-editable-ips')?.value || '';
2702
+ const editableIps = String(editableIpsRaw).split('\n').map((ip) => ip.trim()).filter((ip) => Boolean(ip));
2248
2703
 
2249
2704
  await apiRequest(`${API_BASE}/settings/api_url`, {
2250
2705
  method: 'PUT',
@@ -2264,17 +2719,239 @@ function initApp() {
2264
2719
  method: 'PUT',
2265
2720
  body: JSON.stringify({ value: headers }),
2266
2721
  });
2722
+ await apiRequest(`${API_BASE}/settings/editable_ip_allowlist`, {
2723
+ method: 'PUT',
2724
+ body: JSON.stringify({ value: editableIps }),
2725
+ });
2267
2726
 
2268
2727
  alert('设置已保存');
2728
+ await loadRuntimeMode();
2729
+ applyReadonlyMode();
2269
2730
  await loadSettings();
2270
2731
  });
2271
2732
 
2272
2733
  document.getElementById('add-setting-header-btn')?.addEventListener('click', () => {
2734
+ if (state.readonly || !state.canAccessSystemSettings) return;
2273
2735
  const list = document.getElementById('setting-headers-list');
2274
2736
  if (!(list instanceof HTMLElement)) return;
2275
2737
  list.appendChild(createSettingHeaderRow('', ''));
2276
2738
  });
2277
2739
 
2740
+ document.getElementById('add-project-user-btn')?.addEventListener('click', () => {
2741
+ if (!state.projectSettingsDraft) return;
2742
+ state.projectSettingsDraft.users.push({ id: '', name: '', ips: [] });
2743
+ renderProjectSettingsEditor();
2744
+ });
2745
+
2746
+ document.getElementById('add-project-profile-btn')?.addEventListener('click', () => {
2747
+ if (!state.projectSettingsDraft) return;
2748
+ state.projectSettingsDraft.profiles.push({
2749
+ id: '',
2750
+ name: '',
2751
+ apply_to_all_users: false,
2752
+ page_access: { sandboxes: true, diaries: true, changes: true, settings: false },
2753
+ sandbox_access: { all: 'none', items: [] },
2754
+ });
2755
+ renderProjectSettingsEditor();
2756
+ });
2757
+
2758
+ document.getElementById('add-project-binding-btn')?.addEventListener('click', () => {
2759
+ if (!state.projectSettingsDraft) return;
2760
+ state.projectSettingsDraft.bindings.push({ user_id: '', profile_ids: [] });
2761
+ renderProjectSettingsEditor();
2762
+ });
2763
+
2764
+ document.getElementById('project-users-list')?.addEventListener('click', (event) => {
2765
+ const target = event.target;
2766
+ if (!(target instanceof HTMLElement)) return;
2767
+ if (!state.projectSettingsDraft) return;
2768
+ if (target.dataset.role === 'add-project-user-ip') {
2769
+ const idx = Number(target.dataset.idx);
2770
+ const input = document.querySelector(`[data-role="project-user-ip-input"][data-idx="${idx}"]`);
2771
+ if (input instanceof HTMLInputElement) {
2772
+ addUserIpAtIndex(idx, input.value);
2773
+ }
2774
+ return;
2775
+ }
2776
+ if (target.dataset.role === 'remove-project-user-ip') {
2777
+ const idx = Number(target.dataset.idx);
2778
+ const ipIdx = Number(target.dataset.ipIdx);
2779
+ if (!Number.isFinite(idx) || idx < 0 || !Number.isFinite(ipIdx) || ipIdx < 0) return;
2780
+ const user = state.projectSettingsDraft.users[idx];
2781
+ if (!user) return;
2782
+ user.ips.splice(ipIdx, 1);
2783
+ renderProjectSettingsEditor();
2784
+ return;
2785
+ }
2786
+ if (target.dataset.role !== 'remove-project-user') return;
2787
+ const idx = Number(target.dataset.idx);
2788
+ if (!Number.isFinite(idx) || idx < 0) return;
2789
+ const removed = state.projectSettingsDraft.users[idx];
2790
+ state.projectSettingsDraft.users.splice(idx, 1);
2791
+ if (removed?.id) {
2792
+ state.projectSettingsDraft.bindings = (state.projectSettingsDraft.bindings || []).filter((binding) => binding.user_id !== removed.id);
2793
+ }
2794
+ renderProjectSettingsEditor();
2795
+ });
2796
+
2797
+ document.getElementById('project-users-list')?.addEventListener('input', (event) => {
2798
+ const target = event.target;
2799
+ if (!(target instanceof HTMLInputElement) || !state.projectSettingsDraft) return;
2800
+ const idx = Number(target.dataset.idx);
2801
+ if (!Number.isFinite(idx) || idx < 0) return;
2802
+ const user = state.projectSettingsDraft.users[idx];
2803
+ if (!user) return;
2804
+ if (target.dataset.role === 'project-user-id') {
2805
+ user.id = target.value.trim();
2806
+ } else if (target.dataset.role === 'project-user-name') {
2807
+ user.name = target.value.trim();
2808
+ }
2809
+ });
2810
+
2811
+ document.getElementById('project-users-list')?.addEventListener('keydown', (event) => {
2812
+ const target = event.target;
2813
+ if (!(target instanceof HTMLInputElement) || !state.projectSettingsDraft) return;
2814
+ if (target.dataset.role !== 'project-user-ip-input') return;
2815
+ if (event.key !== 'Enter') return;
2816
+ event.preventDefault();
2817
+ const idx = Number(target.dataset.idx);
2818
+ addUserIpAtIndex(idx, target.value);
2819
+ });
2820
+
2821
+ document.getElementById('project-profiles-list')?.addEventListener('click', (event) => {
2822
+ const target = event.target;
2823
+ if (!(target instanceof HTMLElement) || !state.projectSettingsDraft) return;
2824
+ const profileIdx = Number(target.dataset.idx);
2825
+ if (!Number.isFinite(profileIdx) || profileIdx < 0) return;
2826
+ if (target.dataset.role === 'remove-project-profile') {
2827
+ const removed = state.projectSettingsDraft.profiles[profileIdx];
2828
+ state.projectSettingsDraft.profiles.splice(profileIdx, 1);
2829
+ if (removed?.id) {
2830
+ state.projectSettingsDraft.bindings.forEach((binding) => {
2831
+ binding.profile_ids = (binding.profile_ids || []).filter((id) => id !== removed.id);
2832
+ });
2833
+ }
2834
+ renderProjectSettingsEditor();
2835
+ return;
2836
+ }
2837
+ if (target.dataset.role === 'add-project-profile-sandbox-item') {
2838
+ const profile = state.projectSettingsDraft.profiles[profileIdx];
2839
+ if (!profile) return;
2840
+ profile.sandbox_access.items.push({ sandbox_id: '', access: 'read' });
2841
+ renderProjectSettingsEditor();
2842
+ return;
2843
+ }
2844
+ if (target.dataset.role === 'remove-project-profile-sandbox-item') {
2845
+ const profile = state.projectSettingsDraft.profiles[profileIdx];
2846
+ if (!profile) return;
2847
+ const itemIdx = Number(target.dataset.itemIdx);
2848
+ if (!Number.isFinite(itemIdx) || itemIdx < 0) return;
2849
+ profile.sandbox_access.items.splice(itemIdx, 1);
2850
+ renderProjectSettingsEditor();
2851
+ }
2852
+ });
2853
+
2854
+ document.getElementById('project-profiles-list')?.addEventListener('input', (event) => {
2855
+ const target = event.target;
2856
+ if (!(target instanceof HTMLInputElement) || !state.projectSettingsDraft) return;
2857
+ const profileIdx = Number(target.dataset.idx);
2858
+ if (!Number.isFinite(profileIdx) || profileIdx < 0) return;
2859
+ const profile = state.projectSettingsDraft.profiles[profileIdx];
2860
+ if (!profile) return;
2861
+ if (target.dataset.role === 'project-profile-id') {
2862
+ profile.id = target.value.trim();
2863
+ return;
2864
+ }
2865
+ if (target.dataset.role === 'project-profile-name') {
2866
+ profile.name = target.value.trim();
2867
+ return;
2868
+ }
2869
+ if (target.dataset.role === 'project-profile-apply-all') {
2870
+ profile.apply_to_all_users = target.checked;
2871
+ return;
2872
+ }
2873
+ if (target.dataset.role === 'project-profile-page') {
2874
+ const page = target.dataset.page;
2875
+ if (page === 'sandboxes' || page === 'diaries' || page === 'changes' || page === 'settings') {
2876
+ profile.page_access[page] = target.checked;
2877
+ }
2878
+ return;
2879
+ }
2880
+ });
2881
+
2882
+ document.getElementById('project-profiles-list')?.addEventListener('change', (event) => {
2883
+ const target = event.target;
2884
+ if (!(target instanceof HTMLSelectElement) || !state.projectSettingsDraft) return;
2885
+ const profileIdx = Number(target.dataset.idx);
2886
+ if (!Number.isFinite(profileIdx) || profileIdx < 0) return;
2887
+ const profile = state.projectSettingsDraft.profiles[profileIdx];
2888
+ if (!profile) return;
2889
+ if (target.dataset.role === 'project-profile-all-access') {
2890
+ profile.sandbox_access.all = target.value || 'none';
2891
+ return;
2892
+ }
2893
+ if (target.dataset.role === 'project-profile-sandbox-id') {
2894
+ const itemIdx = Number(target.dataset.itemIdx);
2895
+ if (!Number.isFinite(itemIdx) || itemIdx < 0) return;
2896
+ const item = profile.sandbox_access.items[itemIdx];
2897
+ if (item) item.sandbox_id = String(target.value || '').trim();
2898
+ return;
2899
+ }
2900
+ if (target.dataset.role === 'project-profile-sandbox-access') {
2901
+ const itemIdx = Number(target.dataset.itemIdx);
2902
+ if (!Number.isFinite(itemIdx) || itemIdx < 0) return;
2903
+ const item = profile.sandbox_access.items[itemIdx];
2904
+ if (item) item.access = target.value || 'none';
2905
+ }
2906
+ });
2907
+
2908
+ document.getElementById('project-bindings-list')?.addEventListener('click', (event) => {
2909
+ const target = event.target;
2910
+ if (!(target instanceof HTMLElement) || !state.projectSettingsDraft) return;
2911
+ if (target.dataset.role !== 'remove-project-binding') return;
2912
+ const idx = Number(target.dataset.idx);
2913
+ if (!Number.isFinite(idx) || idx < 0) return;
2914
+ state.projectSettingsDraft.bindings.splice(idx, 1);
2915
+ renderProjectSettingsEditor();
2916
+ });
2917
+
2918
+ document.getElementById('project-bindings-list')?.addEventListener('change', (event) => {
2919
+ const target = event.target;
2920
+ if (!state.projectSettingsDraft) return;
2921
+ const idx = Number(target instanceof HTMLElement ? target.dataset.idx : NaN);
2922
+ if (!Number.isFinite(idx) || idx < 0) return;
2923
+ const binding = state.projectSettingsDraft.bindings[idx];
2924
+ if (!binding) return;
2925
+ if (target instanceof HTMLSelectElement && target.dataset.role === 'project-binding-user') {
2926
+ binding.user_id = target.value || '';
2927
+ return;
2928
+ }
2929
+ if (target instanceof HTMLInputElement && target.dataset.role === 'project-binding-profile') {
2930
+ const profileId = String(target.dataset.profileId || '').trim();
2931
+ const set = new Set(binding.profile_ids || []);
2932
+ if (target.checked) set.add(profileId);
2933
+ else set.delete(profileId);
2934
+ binding.profile_ids = Array.from(set);
2935
+ }
2936
+ });
2937
+
2938
+ document.getElementById('save-project-settings-btn')?.addEventListener('click', async () => {
2939
+ if (state.readonly || (!state.fullAccess && !state.pageAccess.settings)) return;
2940
+ try {
2941
+ const payload = collectProjectSettingsPayload();
2942
+ await apiRequest(`${API_BASE}/project-settings`, {
2943
+ method: 'PUT',
2944
+ body: JSON.stringify(payload),
2945
+ });
2946
+ alert('项目设置已保存');
2947
+ await loadRuntimeMode();
2948
+ applyReadonlyMode();
2949
+ await loadProjectSettings();
2950
+ } catch (error) {
2951
+ alert(`项目设置保存失败: ${error?.message || error}`);
2952
+ }
2953
+ });
2954
+
2278
2955
  document.getElementById('work-item-search')?.addEventListener('input', (e) => {
2279
2956
  state.workItemSearch = e.target.value || '';
2280
2957
  renderWorkTree();
@@ -2288,6 +2965,7 @@ function initApp() {
2288
2965
  document.getElementById('work-tree-view-mode')?.addEventListener('change', (e) => {
2289
2966
  const nextMode = e.target.value || 'full';
2290
2967
  applyWorkTreeViewMode(nextMode);
2968
+ persistWorkTreeViewModePreference();
2291
2969
  renderWorkTree();
2292
2970
  });
2293
2971
 
@@ -2411,12 +3089,13 @@ function initApp() {
2411
3089
  clearTimeout(resizeRenderTimer);
2412
3090
  }
2413
3091
  resizeRenderTimer = setTimeout(() => {
3092
+ applySandboxLayoutHeight();
2414
3093
  renderWorkTree();
2415
3094
  }, 120);
2416
3095
  });
2417
3096
 
2418
3097
  window.addEventListener('hashchange', handleRoute);
2419
- handleRoute();
3098
+ await handleRoute();
2420
3099
 
2421
3100
  async function handleRoute() {
2422
3101
  const hash = window.location.hash.slice(1) || '/';
@@ -2428,13 +3107,31 @@ function initApp() {
2428
3107
  }
2429
3108
 
2430
3109
  if (pathHash === '/') {
2431
- showPage('sandboxes');
2432
- await loadSandboxes();
3110
+ if (state.pageAccess.sandboxes || state.fullAccess) {
3111
+ showPage('sandboxes');
3112
+ await loadSandboxes();
3113
+ } else if (state.pageAccess.diaries) {
3114
+ window.location.hash = '/diaries';
3115
+ } else if (state.pageAccess.changes) {
3116
+ window.location.hash = '/changes';
3117
+ } else if (state.pageAccess.settings) {
3118
+ window.location.hash = '/settings';
3119
+ } else if (state.canAccessSystemSettings) {
3120
+ window.location.hash = '/system-settings';
3121
+ }
2433
3122
  } else if (pathHash === '/sandboxes') {
3123
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
3124
+ window.location.hash = '/';
3125
+ return;
3126
+ }
2434
3127
  showPage('sandboxes');
2435
3128
  await loadSandboxes();
2436
3129
  } else if (pathHash.startsWith('/sandbox/')) {
2437
3130
  const id = decodeURIComponent(pathHash.split('/')[2] || '');
3131
+ if (!canReadSandboxById(id)) {
3132
+ window.location.hash = '/';
3133
+ return;
3134
+ }
2438
3135
  showPage('sandbox-detail');
2439
3136
  await loadSandbox(id);
2440
3137
  const targetNodeId = String(query.get('node_id') || '').trim();
@@ -2444,15 +3141,34 @@ function initApp() {
2444
3141
  showNodeEntityDrawer(targetNodeId, preferredFilter);
2445
3142
  }
2446
3143
  } else if (pathHash === '/diaries') {
3144
+ if (!state.pageAccess.diaries && !state.fullAccess) {
3145
+ window.location.hash = '/';
3146
+ return;
3147
+ }
2447
3148
  showPage('diaries');
2448
3149
  await loadDiaries();
2449
3150
  await loadSandboxes();
2450
3151
  } else if (pathHash === '/changes') {
3152
+ if (!state.pageAccess.changes && !state.fullAccess) {
3153
+ window.location.hash = '/';
3154
+ return;
3155
+ }
2451
3156
  showPage('changes');
2452
3157
  await loadSandboxes();
2453
3158
  await loadChanges();
2454
3159
  } else if (pathHash === '/settings') {
3160
+ if (!state.pageAccess.settings && !state.fullAccess) {
3161
+ window.location.hash = '/';
3162
+ return;
3163
+ }
2455
3164
  showPage('settings');
3165
+ await loadProjectSettings();
3166
+ } else if (pathHash === '/system-settings') {
3167
+ if (!state.canAccessSystemSettings) {
3168
+ window.location.hash = '/';
3169
+ return;
3170
+ }
3171
+ showPage('system-settings');
2456
3172
  await loadSettings();
2457
3173
  }
2458
3174
  }