batch-run-local 1.1.1 → 1.2.0

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/web/main.js CHANGED
@@ -1,9 +1,16 @@
1
+ const UNGROUPED_TAG = "";
2
+ const UNGROUPED_TAG_LABEL = "未分组";
3
+
1
4
  const state = {
2
5
  projects: [],
6
+ tagOrder: [],
7
+ branches: [],
3
8
  currentProjectId: undefined,
4
9
  draggedProjectId: undefined,
10
+ draggedTagKey: undefined,
5
11
  suppressProjectClick: false,
6
12
  editingProjectId: undefined,
13
+ tagExpandState: new Map(),
7
14
  };
8
15
 
9
16
  const projectList = document.querySelector("#projectList");
@@ -19,10 +26,16 @@ const closeProjectConfig = document.querySelector("#closeProjectConfig");
19
26
  const projectConfigModal = document.querySelector("#projectConfigModal");
20
27
  const projectNameInput = document.querySelector("#projectName");
21
28
  const projectRootInput = document.querySelector("#projectRoot");
29
+ const projectTagInput = document.querySelector("#projectTag");
30
+ const tagOptions = document.querySelector("#tagOptions");
22
31
  const projectConfigTitle = document.querySelector("#projectConfigTitle");
23
32
  const projectSubmit = document.querySelector("#projectSubmit");
33
+ const branchPanel = document.querySelector("#branchPanel");
34
+ const branchSelect = document.querySelector("#branchSelect");
24
35
 
25
- openProjectConfig.addEventListener("click", openCreateProjectModal);
36
+ openProjectConfig.addEventListener("click", () => {
37
+ openCreateProjectModal().catch((error) => setStatus(error.message));
38
+ });
26
39
 
27
40
  closeProjectConfig.addEventListener("click", closeConfigModal);
28
41
 
@@ -44,6 +57,7 @@ projectForm.addEventListener("submit", async (event) => {
44
57
  const formData = new FormData(projectForm);
45
58
  const name = String(formData.get("name") ?? "");
46
59
  const root = String(formData.get("root") ?? "");
60
+ const tag = String(formData.get("tag") ?? "");
47
61
  const editingProjectId = state.editingProjectId;
48
62
 
49
63
  await request(editingProjectId ? `/api/projects/${editingProjectId}` : "/api/projects", {
@@ -51,7 +65,7 @@ projectForm.addEventListener("submit", async (event) => {
51
65
  headers: {
52
66
  "Content-Type": "application/json",
53
67
  },
54
- body: JSON.stringify({ name, root }),
68
+ body: JSON.stringify({ name, root, tag }),
55
69
  });
56
70
 
57
71
  projectForm.reset();
@@ -73,97 +87,340 @@ openProjectRoot.addEventListener("click", async () => {
73
87
  setStatus("已打开项目目录。");
74
88
  });
75
89
 
90
+ branchSelect.addEventListener("change", async () => {
91
+ if (!state.currentProjectId || !branchSelect.value) {
92
+ return;
93
+ }
94
+
95
+ const branch = branchSelect.value;
96
+ branchSelect.disabled = true;
97
+ scriptList.textContent = `正在切换到 ${branch}...`;
98
+
99
+ try {
100
+ const data = await request(`/api/projects/${state.currentProjectId}/branches/switch`, {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ },
105
+ body: JSON.stringify({ branch }),
106
+ });
107
+ renderBranches(data);
108
+ renderScripts(state.currentProjectId, data.scripts);
109
+ setStatus(`已切换到分支:${data.current ?? branch}`);
110
+ } finally {
111
+ branchSelect.disabled = false;
112
+ }
113
+ });
114
+
76
115
  async function loadProjects() {
77
116
  const data = await request("/api/projects");
78
117
  state.projects = data.projects;
118
+ state.tagOrder = data.tagOrder ?? deriveTagOrder(state.projects);
79
119
  if (!state.projects.some((project) => project.id === state.currentProjectId)) {
80
120
  state.currentProjectId = undefined;
121
+ state.branches = [];
81
122
  currentProjectTitle.textContent = "请选择项目";
82
123
  openProjectRoot.disabled = true;
124
+ hideBranchPanel();
83
125
  }
84
126
  renderProjects();
85
127
  }
86
128
 
129
+ async function loadTagOptions() {
130
+ const data = await request("/api/tags");
131
+ tagOptions.innerHTML = data.tags
132
+ .map((tag) => `<option value="${escapeHtml(tag)}"></option>`)
133
+ .join("");
134
+ }
135
+
136
+ function deriveTagOrder(projects) {
137
+ const keys = [];
138
+ const seen = new Set();
139
+
140
+ for (const project of projects) {
141
+ const tagKey = getProjectTagKey(project);
142
+ if (!seen.has(tagKey)) {
143
+ seen.add(tagKey);
144
+ keys.push(tagKey);
145
+ }
146
+ }
147
+
148
+ return keys;
149
+ }
150
+
151
+ function getProjectTagKey(project) {
152
+ return project.tag ?? UNGROUPED_TAG;
153
+ }
154
+
155
+ function getTagLabel(tagKey) {
156
+ return tagKey === UNGROUPED_TAG ? UNGROUPED_TAG_LABEL : tagKey;
157
+ }
158
+
159
+ function getProjectsByTag(tagKey) {
160
+ return state.projects.filter((project) => getProjectTagKey(project) === tagKey);
161
+ }
162
+
163
+ function isTagExpanded(tagKey) {
164
+ if (state.tagExpandState.has(tagKey)) {
165
+ return state.tagExpandState.get(tagKey);
166
+ }
167
+
168
+ return tagKey === UNGROUPED_TAG;
169
+ }
170
+
171
+ function toggleTagExpand(tagKey) {
172
+ state.tagExpandState.set(tagKey, !isTagExpanded(tagKey));
173
+ renderProjects();
174
+ }
175
+
87
176
  function renderProjects() {
177
+ const tagOrder = state.tagOrder.length > 0 ? state.tagOrder : deriveTagOrder(state.projects);
88
178
  projectCount.textContent = `共 ${state.projects.length} 个项目`;
89
179
 
90
180
  if (state.projects.length === 0) {
91
- projectList.innerHTML = `<div class="empty">暂无项目,请点击“项目配置”进行配置。</div>`;
181
+ projectList.innerHTML = `<div class="empty">暂无项目,请点击“新增项目”进行配置。</div>`;
92
182
  currentProjectTitle.textContent = "请选择项目";
93
183
  openProjectRoot.disabled = true;
184
+ hideBranchPanel();
94
185
  scriptList.textContent = "选择左侧项目后读取 scripts";
95
186
  return;
96
187
  }
97
188
 
98
189
  projectList.innerHTML = "";
99
190
 
100
- for (const project of state.projects) {
101
- const card = document.createElement("article");
102
- card.className = `project-card ${project.id === state.currentProjectId ? "active" : ""}`;
103
- card.draggable = true;
104
- card.dataset.projectId = project.id;
105
- card.innerHTML = `
106
- <div class="project-card-main">
107
- <h3>${escapeHtml(project.name)}</h3>
108
- <div class="path">${escapeHtml(project.root)}</div>
109
- </div>
110
- <div class="project-actions">
111
- <button class="project-edit" type="button" aria-label="编辑 ${escapeHtml(project.name)}">编辑</button>
112
- <button class="danger project-delete" type="button" aria-label="删除 ${escapeHtml(project.name)}">删除</button>
113
- </div>
191
+ for (const tagKey of tagOrder) {
192
+ const projects = getProjectsByTag(tagKey);
193
+ if (projects.length === 0) {
194
+ continue;
195
+ }
196
+
197
+ const expanded = isTagExpanded(tagKey);
198
+ const group = document.createElement("section");
199
+ group.className = `tag-group${expanded ? "" : " collapsed"}`;
200
+ group.dataset.tagKey = tagKey;
201
+
202
+ const header = document.createElement("div");
203
+ header.className = "tag-header";
204
+ header.dataset.tagKey = tagKey;
205
+ header.innerHTML = `
206
+ <button class="tag-toggle" type="button" aria-expanded="${expanded}" aria-label="${expanded ? "折叠" : "展开"} ${escapeHtml(getTagLabel(tagKey))}">
207
+ ${expanded ? "▼" : "▶"}
208
+ </button>
209
+ <span class="tag-title" draggable="true">${escapeHtml(getTagLabel(tagKey))}</span>
210
+ <span class="tag-count">${projects.length}</span>
114
211
  `;
115
-
116
- card.addEventListener("click", () => {
117
- if (state.suppressProjectClick) {
118
- return;
119
- }
120
- selectProject(project.id);
212
+ header.querySelector(".tag-toggle").addEventListener("click", (event) => {
213
+ event.stopPropagation();
214
+ toggleTagExpand(tagKey);
121
215
  });
122
- card.addEventListener("dragstart", (event) => {
123
- state.draggedProjectId = project.id;
124
- state.suppressProjectClick = true;
125
- event.dataTransfer.effectAllowed = "move";
126
- event.dataTransfer.setData("text/plain", project.id);
127
- card.classList.add("dragging");
216
+ bindTagHeaderDrag(header, tagKey);
217
+
218
+ const projectsContainer = document.createElement("div");
219
+ projectsContainer.className = "tag-projects";
220
+ projectsContainer.dataset.tagKey = tagKey;
221
+ projectsContainer.hidden = !expanded;
222
+ bindTagProjectsDropZone(projectsContainer, tagKey);
223
+
224
+ for (const project of projects) {
225
+ projectsContainer.append(createProjectCard(project, tagKey));
226
+ }
227
+
228
+ group.append(header, projectsContainer);
229
+ projectList.append(group);
230
+ }
231
+ }
232
+
233
+ function createProjectCard(project, tagKey) {
234
+ const card = document.createElement("article");
235
+ card.className = `project-card ${project.id === state.currentProjectId ? "active" : ""}`;
236
+ card.draggable = true;
237
+ card.dataset.projectId = project.id;
238
+ card.dataset.tagKey = tagKey;
239
+ card.innerHTML = `
240
+ <div class="project-card-main">
241
+ <h3>${escapeHtml(project.name)}</h3>
242
+ <div class="path">${escapeHtml(project.root)}</div>
243
+ </div>
244
+ <div class="project-actions">
245
+ <button class="project-edit" type="button" aria-label="编辑 ${escapeHtml(project.name)}">编辑</button>
246
+ <button class="danger project-delete" type="button" aria-label="删除 ${escapeHtml(project.name)}">删除</button>
247
+ </div>
248
+ `;
249
+
250
+ card.addEventListener("click", () => {
251
+ if (state.suppressProjectClick) {
252
+ return;
253
+ }
254
+ selectProject(project.id);
255
+ });
256
+ card.addEventListener("dragstart", (event) => {
257
+ state.draggedProjectId = project.id;
258
+ state.draggedTagKey = undefined;
259
+ state.suppressProjectClick = true;
260
+ event.dataTransfer.effectAllowed = "move";
261
+ event.dataTransfer.setData("text/plain", project.id);
262
+ card.classList.add("dragging");
263
+ });
264
+ card.addEventListener("dragover", (event) => {
265
+ if (!state.draggedProjectId || state.draggedProjectId === project.id) {
266
+ return;
267
+ }
268
+ event.preventDefault();
269
+ event.stopPropagation();
270
+ event.dataTransfer.dropEffect = "move";
271
+ card.classList.toggle("drop-after", shouldDropAfter(event, card));
272
+ });
273
+ card.addEventListener("dragleave", () => {
274
+ card.classList.remove("drop-after");
275
+ });
276
+ card.addEventListener("drop", (event) => {
277
+ if (!state.draggedProjectId) {
278
+ return;
279
+ }
280
+
281
+ event.preventDefault();
282
+ event.stopPropagation();
283
+ card.classList.remove("drop-after");
284
+ moveProject(
285
+ state.draggedProjectId,
286
+ project.id,
287
+ shouldDropAfter(event, card),
288
+ getProjectTagKey(project),
289
+ ).catch((error) => {
290
+ setStatus(error.message);
128
291
  });
129
- card.addEventListener("dragover", (event) => {
130
- if (!state.draggedProjectId || state.draggedProjectId === project.id) {
131
- return;
292
+ });
293
+ card.addEventListener("dragend", () => {
294
+ state.draggedProjectId = undefined;
295
+ card.classList.remove("dragging", "drop-after");
296
+ clearTagDropIndicators();
297
+ setTimeout(() => {
298
+ state.suppressProjectClick = false;
299
+ }, 0);
300
+ });
301
+ card.querySelector(".project-delete").addEventListener("click", (event) => {
302
+ event.stopPropagation();
303
+ deleteProject(project);
304
+ });
305
+ card.querySelector(".project-edit").addEventListener("click", (event) => {
306
+ event.stopPropagation();
307
+ openEditProjectModal(project).catch((error) => setStatus(error.message));
308
+ });
309
+
310
+ return card;
311
+ }
312
+
313
+ function bindTagHeaderDrag(header, tagKey) {
314
+ const dragHandle = header.querySelector(".tag-title");
315
+
316
+ dragHandle.addEventListener("dragstart", (event) => {
317
+ state.draggedTagKey = tagKey;
318
+ state.draggedProjectId = undefined;
319
+ event.dataTransfer.effectAllowed = "move";
320
+ event.dataTransfer.setData("application/x-tag-key", tagKey);
321
+ header.classList.add("dragging");
322
+ });
323
+ header.addEventListener("dragover", (event) => {
324
+ if (state.draggedProjectId) {
325
+ const draggedProject = state.projects.find((project) => project.id === state.draggedProjectId);
326
+ if (draggedProject && getProjectTagKey(draggedProject) !== tagKey) {
327
+ event.preventDefault();
328
+ event.stopPropagation();
329
+ event.dataTransfer.dropEffect = "move";
330
+ header.classList.add("collapsed-drop-target");
132
331
  }
133
- event.preventDefault();
134
- event.dataTransfer.dropEffect = "move";
135
- card.classList.toggle("drop-after", shouldDropAfter(event, card));
136
- });
137
- card.addEventListener("dragleave", () => {
138
- card.classList.remove("drop-after");
139
- });
140
- card.addEventListener("drop", (event) => {
141
- if (!state.draggedProjectId) {
332
+ return;
333
+ }
334
+
335
+ if (state.draggedTagKey === undefined || state.draggedTagKey === tagKey) {
336
+ return;
337
+ }
338
+ event.preventDefault();
339
+ event.dataTransfer.dropEffect = "move";
340
+ header.classList.toggle("drop-after", shouldDropAfter(event, header));
341
+ });
342
+ header.addEventListener("dragleave", (event) => {
343
+ if (event.currentTarget.contains(event.relatedTarget)) {
344
+ return;
345
+ }
346
+ header.classList.remove("drop-after", "collapsed-drop-target");
347
+ });
348
+ header.addEventListener("drop", (event) => {
349
+ if (state.draggedProjectId) {
350
+ const draggedProject = state.projects.find((project) => project.id === state.draggedProjectId);
351
+ if (!draggedProject || getProjectTagKey(draggedProject) === tagKey) {
142
352
  return;
143
353
  }
144
354
 
145
355
  event.preventDefault();
146
- card.classList.remove("drop-after");
147
- moveProject(state.draggedProjectId, project.id, shouldDropAfter(event, card)).catch((error) => {
356
+ event.stopPropagation();
357
+ header.classList.remove("collapsed-drop-target");
358
+ state.tagExpandState.set(tagKey, true);
359
+ moveProjectToTagEnd(state.draggedProjectId, tagKey).catch((error) => {
148
360
  setStatus(error.message);
149
361
  });
362
+ return;
363
+ }
364
+
365
+ if (state.draggedTagKey === undefined || state.draggedTagKey === tagKey) {
366
+ return;
367
+ }
368
+
369
+ event.preventDefault();
370
+ event.stopPropagation();
371
+ header.classList.remove("drop-after");
372
+ moveTagGroup(state.draggedTagKey, tagKey, shouldDropAfter(event, header)).catch((error) => {
373
+ setStatus(error.message);
150
374
  });
151
- card.addEventListener("dragend", () => {
152
- state.draggedProjectId = undefined;
153
- card.classList.remove("dragging", "drop-after");
154
- setTimeout(() => {
155
- state.suppressProjectClick = false;
156
- }, 0);
157
- });
158
- card.querySelector(".project-delete").addEventListener("click", (event) => {
159
- event.stopPropagation();
160
- deleteProject(project);
161
- });
162
- card.querySelector(".project-edit").addEventListener("click", (event) => {
163
- event.stopPropagation();
164
- openEditProjectModal(project);
375
+ });
376
+ dragHandle.addEventListener("dragend", () => {
377
+ state.draggedTagKey = undefined;
378
+ header.classList.remove("dragging", "drop-after", "collapsed-drop-target");
379
+ clearTagDropIndicators();
380
+ });
381
+ }
382
+
383
+ function bindTagProjectsDropZone(container, tagKey) {
384
+ container.addEventListener("dragover", (event) => {
385
+ if (!state.draggedProjectId) {
386
+ return;
387
+ }
388
+
389
+ const draggedProject = state.projects.find((project) => project.id === state.draggedProjectId);
390
+ if (!draggedProject || getProjectTagKey(draggedProject) === tagKey) {
391
+ return;
392
+ }
393
+
394
+ event.preventDefault();
395
+ event.stopPropagation();
396
+ event.dataTransfer.dropEffect = "move";
397
+ container.classList.add("drag-over");
398
+ });
399
+ container.addEventListener("dragleave", (event) => {
400
+ if (event.currentTarget.contains(event.relatedTarget)) {
401
+ return;
402
+ }
403
+ container.classList.remove("drag-over");
404
+ });
405
+ container.addEventListener("drop", (event) => {
406
+ if (!state.draggedProjectId) {
407
+ return;
408
+ }
409
+
410
+ event.preventDefault();
411
+ event.stopPropagation();
412
+ container.classList.remove("drag-over");
413
+ moveProjectToTagEnd(state.draggedProjectId, tagKey).catch((error) => {
414
+ setStatus(error.message);
165
415
  });
166
- projectList.append(card);
416
+ });
417
+ }
418
+
419
+ function clearTagDropIndicators() {
420
+ for (const element of projectList.querySelectorAll(
421
+ ".tag-header.drop-after, .tag-header.collapsed-drop-target, .tag-projects.drag-over",
422
+ )) {
423
+ element.classList.remove("drop-after", "collapsed-drop-target", "drag-over");
167
424
  }
168
425
  }
169
426
 
@@ -175,9 +432,20 @@ async function selectProject(projectId) {
175
432
  currentProjectTitle.textContent = project?.name ?? "请选择项目";
176
433
  openProjectRoot.disabled = !project;
177
434
  scriptList.textContent = "正在读取 scripts...";
178
-
179
- const data = await request(`/api/projects/${projectId}/scripts`);
180
- renderScripts(projectId, data.scripts);
435
+ branchPanel.hidden = false;
436
+ branchSelect.innerHTML = `<option value="">读取中...</option>`;
437
+ branchSelect.disabled = true;
438
+
439
+ const [scriptData, branchData] = await Promise.all([
440
+ request(`/api/projects/${projectId}/scripts`),
441
+ request(`/api/projects/${projectId}/branches`).catch((error) => ({
442
+ current: undefined,
443
+ branches: [],
444
+ message: error.message,
445
+ })),
446
+ ]);
447
+ renderBranches(branchData);
448
+ renderScripts(projectId, scriptData.scripts);
181
449
  }
182
450
 
183
451
  function renderScripts(projectId, scripts) {
@@ -218,24 +486,178 @@ async function runScript(projectId, script) {
218
486
  setStatus(`已打开 Git Bash 执行:npm run ${script}${data.pid ? `\nPID: ${data.pid}` : ""}`);
219
487
  }
220
488
 
221
- async function moveProject(draggedProjectId, targetProjectId, insertAfterTarget) {
489
+ function renderBranches(data) {
490
+ state.branches = data.branches ?? [];
491
+
492
+ if (data.message) {
493
+ branchSelect.innerHTML = `<option value="">${escapeHtml(data.message)}</option>`;
494
+ branchSelect.disabled = true;
495
+ return;
496
+ }
497
+
498
+ branchPanel.hidden = false;
499
+
500
+ if (state.branches.length === 0) {
501
+ branchSelect.innerHTML = `<option value="">未读取到分支</option>`;
502
+ branchSelect.disabled = true;
503
+ return;
504
+ }
505
+
506
+ branchSelect.innerHTML = state.branches
507
+ .map((branch) => {
508
+ const selected = branch.current ? " selected" : "";
509
+ return `<option value="${escapeHtml(branch.name)}"${selected}>${escapeHtml(branch.name)}</option>`;
510
+ })
511
+ .join("");
512
+ branchSelect.disabled = false;
513
+ }
514
+
515
+ function hideBranchPanel() {
516
+ branchPanel.hidden = true;
517
+ branchSelect.innerHTML = "";
518
+ branchSelect.disabled = true;
519
+ }
520
+
521
+ async function moveProject(draggedProjectId, targetProjectId, insertAfterTarget, targetTagKey) {
222
522
  if (draggedProjectId === targetProjectId) {
223
523
  return;
224
524
  }
225
525
 
526
+ const draggedProject = state.projects.find((project) => project.id === draggedProjectId);
527
+ if (!draggedProject) {
528
+ return;
529
+ }
530
+
531
+ const normalizedTag = targetTagKey === UNGROUPED_TAG ? undefined : targetTagKey;
532
+ const tagChanged = getProjectTagKey(draggedProject) !== targetTagKey;
533
+
534
+ if (tagChanged) {
535
+ await request(`/api/projects/${draggedProjectId}`, {
536
+ method: "PUT",
537
+ headers: {
538
+ "Content-Type": "application/json",
539
+ },
540
+ body: JSON.stringify({
541
+ name: draggedProject.name,
542
+ root: draggedProject.root,
543
+ tag: normalizedTag,
544
+ }),
545
+ });
546
+ draggedProject.tag = normalizedTag;
547
+ }
548
+
226
549
  const nextProjects = [...state.projects];
227
550
  const draggedIndex = nextProjects.findIndex((project) => project.id === draggedProjectId);
228
551
  if (draggedIndex < 0) {
229
552
  return;
230
553
  }
231
554
 
232
- const [draggedProject] = nextProjects.splice(draggedIndex, 1);
555
+ const [movingProject] = nextProjects.splice(draggedIndex, 1);
556
+ const updatedProject = { ...movingProject, tag: normalizedTag };
233
557
  const targetIndex = nextProjects.findIndex((project) => project.id === targetProjectId);
234
558
  if (targetIndex < 0) {
235
559
  return;
236
560
  }
237
561
 
238
- nextProjects.splice(targetIndex + (insertAfterTarget ? 1 : 0), 0, draggedProject);
562
+ nextProjects.splice(targetIndex + (insertAfterTarget ? 1 : 0), 0, updatedProject);
563
+ await persistProjectOrder(nextProjects, tagChanged ? "项目已移动到新标签。" : "项目排序已保存。");
564
+ }
565
+
566
+ async function moveProjectToTagEnd(draggedProjectId, targetTagKey) {
567
+ const draggedProject = state.projects.find((project) => project.id === draggedProjectId);
568
+ if (!draggedProject) {
569
+ return;
570
+ }
571
+
572
+ const normalizedTag = targetTagKey === UNGROUPED_TAG ? undefined : targetTagKey;
573
+ const tagChanged = getProjectTagKey(draggedProject) !== targetTagKey;
574
+
575
+ if (tagChanged) {
576
+ await request(`/api/projects/${draggedProjectId}`, {
577
+ method: "PUT",
578
+ headers: {
579
+ "Content-Type": "application/json",
580
+ },
581
+ body: JSON.stringify({
582
+ name: draggedProject.name,
583
+ root: draggedProject.root,
584
+ tag: normalizedTag,
585
+ }),
586
+ });
587
+ draggedProject.tag = normalizedTag;
588
+ }
589
+
590
+ const nextProjects = state.projects.filter((project) => project.id !== draggedProjectId);
591
+ const updatedProject = { ...draggedProject, tag: normalizedTag };
592
+ const projectsInTargetTag = nextProjects.filter((project) => getProjectTagKey(project) === targetTagKey);
593
+ const lastProject = projectsInTargetTag[projectsInTargetTag.length - 1];
594
+
595
+ if (lastProject) {
596
+ const lastIndex = nextProjects.findIndex((project) => project.id === lastProject.id);
597
+ nextProjects.splice(lastIndex + 1, 0, updatedProject);
598
+ } else {
599
+ const tagOrder = state.tagOrder.length > 0 ? state.tagOrder : deriveTagOrder(state.projects);
600
+ const targetTagIndex = tagOrder.indexOf(targetTagKey);
601
+ if (targetTagIndex < 0) {
602
+ nextProjects.push(updatedProject);
603
+ } else {
604
+ let insertIndex = nextProjects.length;
605
+ for (let index = targetTagIndex + 1; index < tagOrder.length; index += 1) {
606
+ const nextTagProjectIndex = nextProjects.findIndex(
607
+ (project) => getProjectTagKey(project) === tagOrder[index],
608
+ );
609
+ if (nextTagProjectIndex >= 0) {
610
+ insertIndex = nextTagProjectIndex;
611
+ break;
612
+ }
613
+ }
614
+ nextProjects.splice(insertIndex, 0, updatedProject);
615
+ }
616
+ }
617
+
618
+ await persistProjectOrder(nextProjects, "项目已移动到新标签。");
619
+ }
620
+
621
+ async function moveTagGroup(draggedTagKey, targetTagKey, insertAfterTarget) {
622
+ if (draggedTagKey === targetTagKey) {
623
+ return;
624
+ }
625
+
626
+ const nextTagOrder = [...(state.tagOrder.length > 0 ? state.tagOrder : deriveTagOrder(state.projects))];
627
+ const draggedIndex = nextTagOrder.indexOf(draggedTagKey);
628
+ if (draggedIndex < 0) {
629
+ return;
630
+ }
631
+
632
+ nextTagOrder.splice(draggedIndex, 1);
633
+ const targetIndex = nextTagOrder.indexOf(targetTagKey);
634
+ if (targetIndex < 0) {
635
+ return;
636
+ }
637
+
638
+ nextTagOrder.splice(targetIndex + (insertAfterTarget ? 1 : 0), 0, draggedTagKey);
639
+ state.tagOrder = nextTagOrder;
640
+ renderProjects();
641
+
642
+ try {
643
+ const data = await request("/api/tag-order", {
644
+ method: "PUT",
645
+ headers: {
646
+ "Content-Type": "application/json",
647
+ },
648
+ body: JSON.stringify({ tagKeys: nextTagOrder }),
649
+ });
650
+ state.projects = data.projects;
651
+ state.tagOrder = data.tagOrder ?? nextTagOrder;
652
+ renderProjects();
653
+ setStatus("标签排序已保存。");
654
+ } catch (error) {
655
+ await loadProjects();
656
+ throw error;
657
+ }
658
+ }
659
+
660
+ async function persistProjectOrder(nextProjects, successMessage) {
239
661
  state.projects = nextProjects;
240
662
  renderProjects();
241
663
 
@@ -250,8 +672,9 @@ async function moveProject(draggedProjectId, targetProjectId, insertAfterTarget)
250
672
  }),
251
673
  });
252
674
  state.projects = data.projects;
675
+ state.tagOrder = data.tagOrder ?? deriveTagOrder(state.projects);
253
676
  renderProjects();
254
- setStatus("项目排序已保存。");
677
+ setStatus(successMessage);
255
678
  } catch (error) {
256
679
  await loadProjects();
257
680
  throw error;
@@ -263,21 +686,24 @@ function shouldDropAfter(event, element) {
263
686
  return event.clientY > rect.top + rect.height / 2;
264
687
  }
265
688
 
266
- function openCreateProjectModal() {
689
+ async function openCreateProjectModal() {
267
690
  state.editingProjectId = undefined;
268
691
  projectForm.reset();
269
692
  projectConfigTitle.textContent = "项目配置";
270
693
  projectSubmit.textContent = "新增项目";
694
+ await loadTagOptions();
271
695
  projectConfigModal.hidden = false;
272
696
  projectNameInput.focus();
273
697
  }
274
698
 
275
- function openEditProjectModal(project) {
699
+ async function openEditProjectModal(project) {
276
700
  state.editingProjectId = project.id;
277
701
  projectConfigTitle.textContent = "编辑项目";
278
702
  projectSubmit.textContent = "保存修改";
703
+ await loadTagOptions();
279
704
  projectNameInput.value = project.name;
280
705
  projectRootInput.value = project.root;
706
+ projectTagInput.value = project.tag ?? "";
281
707
  projectConfigModal.hidden = false;
282
708
  projectNameInput.focus();
283
709
  }
@@ -299,8 +725,10 @@ async function deleteProject(project) {
299
725
 
300
726
  if (state.currentProjectId === project.id) {
301
727
  state.currentProjectId = undefined;
728
+ state.branches = [];
302
729
  currentProjectTitle.textContent = "请选择项目";
303
730
  openProjectRoot.disabled = true;
731
+ hideBranchPanel();
304
732
  scriptList.textContent = "选择左侧项目后读取 scripts";
305
733
  }
306
734