taskode 0.4.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/public/app.js ADDED
@@ -0,0 +1,1110 @@
1
+ const boardEl = document.getElementById('board')
2
+ const inspectorEl = document.getElementById('inspector')
3
+ const runsEl = document.getElementById('runs')
4
+ const logsEl = document.getElementById('logs')
5
+ const stateEl = document.getElementById('state')
6
+ const authTokenInput = document.getElementById('auth-token')
7
+ const authStatusEl = document.getElementById('auth-status')
8
+ const refreshButton = document.getElementById('refresh-now')
9
+ const saveTokenButton = document.getElementById('save-token')
10
+ const runsSummaryEl = document.getElementById('runs-summary')
11
+ const stateSummaryEl = document.getElementById('state-summary')
12
+ const logsSummaryEl = document.getElementById('logs-summary')
13
+
14
+ const metricEls = {
15
+ total: document.getElementById('metric-total'),
16
+ active: document.getElementById('metric-active'),
17
+ review: document.getElementById('metric-review'),
18
+ done: document.getElementById('metric-done')
19
+ }
20
+
21
+ const TOKEN_KEY = 'taskode.token'
22
+ const POLL_INTERVAL_MS = 3000
23
+ const COLLATOR = new Intl.Collator('zh-CN')
24
+
25
+ const COLUMNS = [
26
+ { status: 'todo', label: '待处理', note: '新任务从这里开始' },
27
+ { status: 'in_progress', label: '进行中', note: '正在执行的任务' },
28
+ { status: 'review', label: '待审核', note: '变更已产出,等待人工处理' },
29
+ { status: 'blocked', label: '阻塞', note: '依赖未满足或暂不可执行' },
30
+ { status: 'failed', label: '失败', note: '需要重试或重新拆解' },
31
+ { status: 'done', label: '已完成', note: '已经应用或确认完成' },
32
+ { status: 'cancelled', label: '已取消', note: '当前不再继续处理' }
33
+ ]
34
+
35
+ const STATUS_LABELS = Object.fromEntries(COLUMNS.map((column) => [column.status, column.label]))
36
+ const PRIORITY_LABELS = {
37
+ high: '高优先级',
38
+ medium: '中优先级',
39
+ low: '低优先级'
40
+ }
41
+ const TRACKER_LABELS = {
42
+ local: '本地',
43
+ linear: 'Linear',
44
+ github: 'GitHub'
45
+ }
46
+ const REVIEW_LABELS = {
47
+ none: '无需审核',
48
+ pending: '待审核',
49
+ approved: '已通过',
50
+ rejected: '已退回'
51
+ }
52
+ const RUN_LABELS = {
53
+ queued: '排队中',
54
+ running: '运行中',
55
+ succeeded: '已成功',
56
+ failed: '已失败',
57
+ interrupted: '已中断'
58
+ }
59
+ const PRIORITY_ORDER = { high: 0, medium: 1, low: 2 }
60
+
61
+ const state = {
62
+ tasks: [],
63
+ runs: [],
64
+ logs: [],
65
+ runtime: null,
66
+ selectedTaskId: null,
67
+ createOpen: false,
68
+ draftTask: createEmptyDraft(),
69
+ diffCache: Object.create(null),
70
+ expandedRuns: new Set(),
71
+ refreshPromise: null,
72
+ dragTaskId: null,
73
+ manualRefreshInFlight: false
74
+ }
75
+
76
+ authTokenInput.value = localStorage.getItem(TOKEN_KEY) || ''
77
+ updateAuthStatus('')
78
+
79
+ saveTokenButton.addEventListener('click', handleSaveToken)
80
+ refreshButton.addEventListener('click', handleManualRefresh)
81
+
82
+ boardEl.addEventListener('click', handleBoardClick)
83
+ boardEl.addEventListener('input', handleBoardInput)
84
+ boardEl.addEventListener('submit', handleBoardSubmit)
85
+ boardEl.addEventListener('keydown', handleBoardKeydown)
86
+ boardEl.addEventListener('dragstart', handleBoardDragStart)
87
+ boardEl.addEventListener('dragover', handleBoardDragOver)
88
+ boardEl.addEventListener('drop', handleBoardDrop)
89
+ boardEl.addEventListener('dragend', handleBoardDragEnd)
90
+
91
+ inspectorEl.addEventListener('click', handleInspectorClick)
92
+ inspectorEl.addEventListener('change', handleInspectorChange)
93
+ runsEl.addEventListener('click', handleRunsClick)
94
+
95
+ document.addEventListener('toggle', handleDetailsToggle, true)
96
+
97
+ refresh()
98
+ setInterval(() => {
99
+ if (shouldDeferRefresh()) return
100
+ refresh({ background: true })
101
+ }, POLL_INTERVAL_MS)
102
+
103
+ function createEmptyDraft() {
104
+ return {
105
+ title: '',
106
+ description: '',
107
+ type: 'todo',
108
+ priority: 'medium',
109
+ dependencies: ''
110
+ }
111
+ }
112
+
113
+ async function handleSaveToken() {
114
+ const token = authTokenInput.value.trim()
115
+
116
+ if (token) localStorage.setItem(TOKEN_KEY, token)
117
+ else localStorage.removeItem(TOKEN_KEY)
118
+
119
+ updateAuthStatus(token ? 'Token 已保存,正在重新验证…' : 'Token 已清空')
120
+ await refresh()
121
+ }
122
+
123
+ async function handleManualRefresh() {
124
+ if (state.manualRefreshInFlight) return
125
+
126
+ state.manualRefreshInFlight = true
127
+ refreshButton.disabled = true
128
+ refreshButton.textContent = '调度中...'
129
+
130
+ try {
131
+ await apiFetch('/api/v1/refresh', { method: 'POST' })
132
+ await refresh()
133
+ } catch (error) {
134
+ updateAuthStatus(error.message, true)
135
+ } finally {
136
+ state.manualRefreshInFlight = false
137
+ refreshButton.disabled = false
138
+ refreshButton.textContent = '立即调度'
139
+ }
140
+ }
141
+
142
+ function handleBoardClick(event) {
143
+ const actionEl = event.target.closest('[data-action]')
144
+ if (actionEl) {
145
+ const action = actionEl.dataset.action
146
+
147
+ if (action === 'open-composer') {
148
+ state.createOpen = true
149
+ renderBoard()
150
+ focusComposerTitle()
151
+ return
152
+ }
153
+
154
+ if (action === 'cancel-create') {
155
+ state.createOpen = false
156
+ state.draftTask = createEmptyDraft()
157
+ renderBoard()
158
+ return
159
+ }
160
+ }
161
+
162
+ const taskCard = event.target.closest('[data-task-card]')
163
+ if (!taskCard) return
164
+ if (event.target.closest('a, button, input, select, textarea, summary')) return
165
+
166
+ selectTask(taskCard.dataset.taskId)
167
+ }
168
+
169
+ function handleBoardInput(event) {
170
+ const field = event.target.dataset.draftField
171
+ if (!field) return
172
+ state.draftTask[field] = event.target.value
173
+ }
174
+
175
+ async function handleBoardSubmit(event) {
176
+ const form = event.target.closest('[data-form="create-task"]')
177
+ if (!form) return
178
+
179
+ event.preventDefault()
180
+
181
+ if (!canCreateTasks()) {
182
+ updateAuthStatus('当前不是本地任务模式,不能在 Web 端创建卡片', true)
183
+ return
184
+ }
185
+
186
+ const title = state.draftTask.title.trim()
187
+ if (!title) {
188
+ updateAuthStatus('任务标题不能为空', true)
189
+ focusComposerTitle()
190
+ return
191
+ }
192
+
193
+ try {
194
+ const response = await apiFetch('/api/tasks', {
195
+ method: 'POST',
196
+ body: JSON.stringify({
197
+ title,
198
+ description: state.draftTask.description.trim(),
199
+ type: state.draftTask.type.trim() || 'todo',
200
+ priority: state.draftTask.priority,
201
+ dependencies: parseDependencies(state.draftTask.dependencies)
202
+ })
203
+ })
204
+
205
+ state.createOpen = false
206
+ state.draftTask = createEmptyDraft()
207
+ state.selectedTaskId = response.task?.id || null
208
+ await refresh()
209
+ } catch (error) {
210
+ updateAuthStatus(error.message, true)
211
+ }
212
+ }
213
+
214
+ function handleBoardKeydown(event) {
215
+ const taskCard = event.target.closest('[data-task-card]')
216
+ if (!taskCard) return
217
+
218
+ if (event.key === 'Enter' || event.key === ' ') {
219
+ event.preventDefault()
220
+ selectTask(taskCard.dataset.taskId)
221
+ }
222
+ }
223
+
224
+ function handleBoardDragStart(event) {
225
+ const taskCard = event.target.closest('[data-task-card][draggable="true"]')
226
+ if (!taskCard) return
227
+
228
+ state.dragTaskId = taskCard.dataset.taskId
229
+ event.dataTransfer.effectAllowed = 'move'
230
+ event.dataTransfer.setData('text/plain', state.dragTaskId)
231
+
232
+ requestAnimationFrame(() => {
233
+ taskCard.classList.add('is-dragging')
234
+ })
235
+ }
236
+
237
+ function handleBoardDragOver(event) {
238
+ const column = event.target.closest('[data-column-status]')
239
+ if (!column || !state.dragTaskId) return
240
+
241
+ const task = getTaskById(state.dragTaskId)
242
+ if (!canMoveTask(task, column.dataset.columnStatus)) return
243
+
244
+ event.preventDefault()
245
+ event.dataTransfer.dropEffect = 'move'
246
+ highlightColumn(column)
247
+ }
248
+
249
+ async function handleBoardDrop(event) {
250
+ const column = event.target.closest('[data-column-status]')
251
+ if (!column || !state.dragTaskId) return
252
+
253
+ clearDropTargets()
254
+ event.preventDefault()
255
+
256
+ const taskId = state.dragTaskId
257
+ state.dragTaskId = null
258
+ const nextStatus = column.dataset.columnStatus
259
+
260
+ await patchTaskStatus(taskId, nextStatus)
261
+ }
262
+
263
+ function handleBoardDragEnd() {
264
+ state.dragTaskId = null
265
+ clearDropTargets()
266
+ boardEl.querySelectorAll('.task-card.is-dragging').forEach((card) => card.classList.remove('is-dragging'))
267
+ }
268
+
269
+ function handleInspectorClick(event) {
270
+ handleRunContainerSummary(event)
271
+
272
+ const actionEl = event.target.closest('[data-action]')
273
+ if (!actionEl) return
274
+
275
+ if (actionEl.dataset.action === 'open-task-link') {
276
+ return
277
+ }
278
+
279
+ if (actionEl.dataset.taskAction === 'select-task') {
280
+ selectTask(actionEl.dataset.taskId, { scroll: true })
281
+ return
282
+ }
283
+
284
+ if (actionEl.dataset.runAction) {
285
+ handleRunAction(actionEl.dataset.runAction, actionEl.dataset.runId, actionEl.dataset.taskId)
286
+ }
287
+ }
288
+
289
+ function handleInspectorChange(event) {
290
+ const select = event.target.closest('[data-task-status-select]')
291
+ if (!select) return
292
+ patchTaskStatus(select.dataset.taskId, select.value)
293
+ }
294
+
295
+ function handleRunsClick(event) {
296
+ handleRunContainerSummary(event)
297
+
298
+ const actionEl = event.target.closest('[data-action]')
299
+ if (!actionEl) return
300
+
301
+ if (actionEl.dataset.taskAction === 'select-task') {
302
+ selectTask(actionEl.dataset.taskId, { scroll: true })
303
+ return
304
+ }
305
+
306
+ if (actionEl.dataset.runAction) {
307
+ handleRunAction(actionEl.dataset.runAction, actionEl.dataset.runId, actionEl.dataset.taskId)
308
+ }
309
+ }
310
+
311
+ function handleRunContainerSummary(event) {
312
+ const summaryEl = event.target.closest('[data-run-summary]')
313
+ if (!summaryEl) return
314
+
315
+ const detailsEl = summaryEl.closest('[data-run-detail]')
316
+ if (!detailsEl) return
317
+
318
+ const runId = detailsEl.dataset.runId
319
+ const willOpen = !detailsEl.open
320
+ if (willOpen) state.expandedRuns.add(runId)
321
+ else state.expandedRuns.delete(runId)
322
+ }
323
+
324
+ function handleDetailsToggle(event) {
325
+ const detailsEl = event.target
326
+ if (!(detailsEl instanceof HTMLDetailsElement)) return
327
+ if (!detailsEl.matches('[data-run-detail]')) return
328
+
329
+ const runId = detailsEl.dataset.runId
330
+ if (!runId) return
331
+
332
+ if (detailsEl.open) state.expandedRuns.add(runId)
333
+ else state.expandedRuns.delete(runId)
334
+ }
335
+
336
+ async function handleRunAction(action, runId, taskId) {
337
+ try {
338
+ if (action === 'select-task') {
339
+ selectTask(taskId, { scroll: true })
340
+ return
341
+ }
342
+
343
+ if (action === 'load-diff') {
344
+ await loadRunDiff(runId)
345
+ return
346
+ }
347
+
348
+ if (action === 'approve') {
349
+ await apiFetch(`/api/runs/${runId}/approve`, { method: 'POST' })
350
+ }
351
+
352
+ if (action === 'reject') {
353
+ const reason = window.prompt('拒绝原因(可选)', '') || ''
354
+ await apiFetch(`/api/runs/${runId}/reject`, {
355
+ method: 'POST',
356
+ body: JSON.stringify({ reason })
357
+ })
358
+ }
359
+
360
+ if (action === 'requeue') {
361
+ await apiFetch(`/api/tasks/${taskId}/requeue`, { method: 'POST' })
362
+ }
363
+
364
+ await refresh()
365
+ } catch (error) {
366
+ updateAuthStatus(error.message, true)
367
+ }
368
+ }
369
+
370
+ async function loadRunDiff(runId) {
371
+ const cacheEntry = state.diffCache[runId]
372
+ if (cacheEntry?.status === 'loading' || cacheEntry?.status === 'loaded') return
373
+
374
+ state.diffCache[runId] = { status: 'loading', text: 'Loading diff...' }
375
+ state.expandedRuns.add(runId)
376
+ renderRuns()
377
+ renderInspector()
378
+
379
+ try {
380
+ const response = await fetchWithAuth(`/api/runs/${runId}/diff`)
381
+
382
+ if (response.status === 401) {
383
+ throw new Error('未授权,请填写正确的 API Token')
384
+ }
385
+
386
+ if (!response.ok) {
387
+ const payload = await safeJson(response)
388
+ throw new Error(payload.error || `Request failed: ${response.status}`)
389
+ }
390
+
391
+ const text = await response.text()
392
+ state.diffCache[runId] = {
393
+ status: 'loaded',
394
+ text: text || 'Diff 为空'
395
+ }
396
+ } catch (error) {
397
+ state.diffCache[runId] = {
398
+ status: 'error',
399
+ text: error.message
400
+ }
401
+ updateAuthStatus(error.message, true)
402
+ }
403
+
404
+ renderRuns()
405
+ renderInspector()
406
+ }
407
+
408
+ async function patchTaskStatus(taskId, nextStatus) {
409
+ const task = getTaskById(taskId)
410
+ if (!canMoveTask(task, nextStatus)) return
411
+
412
+ const previousStatus = task.status
413
+ task.status = nextStatus
414
+ task.updatedAt = new Date().toISOString()
415
+ renderBoard()
416
+ renderInspector()
417
+ updateAuthStatus(`已切换到${formatTaskStatus(nextStatus)},正在保存…`)
418
+
419
+ try {
420
+ await apiFetch(`/api/tasks/${taskId}`, {
421
+ method: 'PATCH',
422
+ body: JSON.stringify({ status: nextStatus })
423
+ })
424
+ await refresh()
425
+ } catch (error) {
426
+ task.status = previousStatus
427
+ renderBoard()
428
+ renderInspector()
429
+ updateAuthStatus(error.message, true)
430
+ }
431
+ }
432
+
433
+ function shouldDeferRefresh() {
434
+ if (state.dragTaskId) return true
435
+
436
+ const activeEl = document.activeElement
437
+ if (!activeEl) return false
438
+ if (!(activeEl instanceof HTMLElement)) return false
439
+
440
+ return Boolean(activeEl.closest('input, textarea, select'))
441
+ }
442
+
443
+ function selectTask(taskId, { scroll = false } = {}) {
444
+ if (!getTaskById(taskId)) return
445
+ state.selectedTaskId = taskId
446
+ renderBoard()
447
+ renderInspector()
448
+
449
+ if (scroll && window.innerWidth <= 1280) {
450
+ inspectorEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
451
+ }
452
+ }
453
+
454
+ function focusComposerTitle() {
455
+ const input = boardEl.querySelector('[data-draft-field="title"]')
456
+ if (input instanceof HTMLInputElement) input.focus()
457
+ }
458
+
459
+ async function refresh({ background = false } = {}) {
460
+ if (state.refreshPromise) return state.refreshPromise
461
+
462
+ state.refreshPromise = (async () => {
463
+ try {
464
+ const [tasksResp, runsResp, logsResp, runtimeResp] = await Promise.all([
465
+ apiFetch('/api/tasks'),
466
+ apiFetch('/api/runs'),
467
+ apiFetch('/api/logs'),
468
+ apiFetch('/api/v1/state')
469
+ ])
470
+
471
+ state.tasks = Array.isArray(tasksResp.tasks) ? tasksResp.tasks : []
472
+ state.runs = Array.isArray(runsResp.runs) ? sortRuns(runsResp.runs) : []
473
+ state.logs = Array.isArray(logsResp.logs) ? logsResp.logs : []
474
+ state.runtime = runtimeResp || null
475
+
476
+ if (state.selectedTaskId && !getTaskById(state.selectedTaskId)) {
477
+ state.selectedTaskId = null
478
+ }
479
+
480
+ render()
481
+ updateAuthStatus('')
482
+ } catch (error) {
483
+ if (!background) render()
484
+ updateAuthStatus(error.message, true)
485
+ } finally {
486
+ state.refreshPromise = null
487
+ }
488
+ })()
489
+
490
+ return state.refreshPromise
491
+ }
492
+
493
+ function render() {
494
+ renderMetrics()
495
+ renderBoard()
496
+ renderInspector()
497
+ renderRuns()
498
+ renderSystemState()
499
+ renderLogs()
500
+ }
501
+
502
+ function renderMetrics() {
503
+ metricEls.total.textContent = String(state.tasks.length)
504
+ metricEls.active.textContent = String(state.tasks.filter((task) => task.status === 'in_progress').length)
505
+ metricEls.review.textContent = String(state.tasks.filter((task) => task.status === 'review').length)
506
+ metricEls.done.textContent = String(state.tasks.filter((task) => task.status === 'done').length)
507
+ }
508
+
509
+ function renderBoard() {
510
+ const runsByTask = buildRunsByTask(state.runs)
511
+
512
+ boardEl.innerHTML = COLUMNS.map((column) => {
513
+ const columnTasks = sortTasksForBoard(state.tasks.filter((task) => task.status === column.status))
514
+ const cards = columnTasks.map((task) => renderTaskCard(task, runsByTask.get(task.id)?.[0] || null)).join('')
515
+ const bodyParts = []
516
+
517
+ if (column.status === 'todo') {
518
+ bodyParts.push(renderComposerCard())
519
+ }
520
+
521
+ bodyParts.push(cards || '<p class="empty-column">暂无卡片</p>')
522
+
523
+ return `
524
+ <section class="kanban-column" data-column-status="${escapeHtml(column.status)}" aria-label="${escapeHtml(column.label)}">
525
+ <div class="column-head">
526
+ <div>
527
+ <div class="column-title-row">
528
+ <span class="column-dot ${escapeHtml(column.status)}" aria-hidden="true"></span>
529
+ <h3 class="column-title">${escapeHtml(column.label)}</h3>
530
+ </div>
531
+ <p class="column-note">${escapeHtml(column.note)}</p>
532
+ </div>
533
+ <span class="column-count">${columnTasks.length}</span>
534
+ </div>
535
+ <div class="column-body">
536
+ ${bodyParts.join('')}
537
+ </div>
538
+ </section>
539
+ `
540
+ }).join('')
541
+ }
542
+
543
+ function renderComposerCard() {
544
+ if (!canCreateTasks()) {
545
+ return `
546
+ <article class="composer-card">
547
+ <div class="composer-disabled">
548
+ 当前是 ${escapeHtml(formatTracker(getTrackerMode()))} 同步模式,首页只展示任务,不在 Web 端新建本地卡片。
549
+ </div>
550
+ </article>
551
+ `
552
+ }
553
+
554
+ if (!state.createOpen) {
555
+ return `
556
+ <article class="composer-card">
557
+ <button type="button" class="composer-trigger" data-action="open-composer">
558
+ 创建任务卡片
559
+ </button>
560
+ </article>
561
+ `
562
+ }
563
+
564
+ return `
565
+ <article class="composer-card">
566
+ <form class="task-composer-form" data-form="create-task">
567
+ <div>
568
+ <label class="token-label" for="draft-title">任务标题</label>
569
+ <input
570
+ id="draft-title"
571
+ data-draft-field="title"
572
+ value="${escapeHtml(state.draftTask.title)}"
573
+ placeholder="例如:重构首页任务看板"
574
+ autocomplete="off"
575
+ />
576
+ </div>
577
+
578
+ <div>
579
+ <label class="token-label" for="draft-description">任务说明</label>
580
+ <textarea
581
+ id="draft-description"
582
+ data-draft-field="description"
583
+ placeholder="补充目标、验收标准、上下文"
584
+ >${escapeHtml(state.draftTask.description)}</textarea>
585
+ </div>
586
+
587
+ <div class="task-composer-grid">
588
+ <div>
589
+ <label class="token-label" for="draft-type">类型</label>
590
+ <input
591
+ id="draft-type"
592
+ data-draft-field="type"
593
+ value="${escapeHtml(state.draftTask.type)}"
594
+ placeholder="todo / bug / feature"
595
+ autocomplete="off"
596
+ />
597
+ </div>
598
+
599
+ <div>
600
+ <label class="token-label" for="draft-priority">优先级</label>
601
+ <select id="draft-priority" data-draft-field="priority">
602
+ ${renderPriorityOptions(state.draftTask.priority)}
603
+ </select>
604
+ </div>
605
+ </div>
606
+
607
+ <div>
608
+ <label class="token-label" for="draft-dependencies">依赖任务</label>
609
+ <input
610
+ id="draft-dependencies"
611
+ data-draft-field="dependencies"
612
+ value="${escapeHtml(state.draftTask.dependencies)}"
613
+ placeholder="多个任务用逗号分隔,例如 T-1, T-2"
614
+ autocomplete="off"
615
+ />
616
+ </div>
617
+
618
+ <div class="composer-actions">
619
+ <button type="submit" class="toolbar-button">创建卡片</button>
620
+ <button type="button" class="ghost-button" data-action="cancel-create">收起</button>
621
+ </div>
622
+ </form>
623
+ </article>
624
+ `
625
+ }
626
+
627
+ function renderTaskCard(task, latestRun) {
628
+ const selected = task.id === state.selectedTaskId
629
+ const metaPills = [
630
+ `<span class="meta-pill">${escapeHtml(task.id)}</span>`,
631
+ `<span class="meta-pill">${escapeHtml(formatPriority(task.priority))}</span>`,
632
+ `<span class="meta-pill">${escapeHtml(formatTracker(task.trackerKind))}</span>`
633
+ ]
634
+
635
+ if (task.dependencies?.length) {
636
+ metaPills.push(`<span class="meta-pill">依赖 ${task.dependencies.length}</span>`)
637
+ }
638
+
639
+ if (latestRun) {
640
+ metaPills.push(`<span class="meta-pill">最近运行 ${escapeHtml(formatRunStatus(latestRun.status))}</span>`)
641
+ }
642
+
643
+ const description = task.description || '暂无说明'
644
+ const trackerHint = latestRun
645
+ ? `最近一次:${formatDate(latestRun.finishedAt || latestRun.startedAt)}`
646
+ : `更新于 ${formatDate(task.updatedAt)}`
647
+
648
+ return `
649
+ <article
650
+ class="task-card${selected ? ' is-selected' : ''}"
651
+ data-task-card
652
+ data-task-id="${escapeHtml(task.id)}"
653
+ draggable="${isLocalTask(task) ? 'true' : 'false'}"
654
+ role="button"
655
+ tabindex="0"
656
+ aria-selected="${selected ? 'true' : 'false'}"
657
+ >
658
+ <div class="task-card-header">
659
+ <div>
660
+ <h3 class="task-card-title">${escapeHtml(task.title)}</h3>
661
+ <p class="task-meta-line">${escapeHtml(trackerHint)}</p>
662
+ </div>
663
+ <span class="status-pill ${escapeHtml(task.status)}">${escapeHtml(formatTaskStatus(task.status))}</span>
664
+ </div>
665
+
666
+ <p class="task-card-body">${escapeHtml(description)}</p>
667
+ <p class="task-meta-line">${escapeHtml(buildTaskMetaLine(task, latestRun))}</p>
668
+ <div class="task-card-footer">${metaPills.join('')}</div>
669
+ ${isLocalTask(task) ? '<p class="drag-hint">拖到其他列即可切换状态</p>' : ''}
670
+ </article>
671
+ `
672
+ }
673
+
674
+ function renderInspector() {
675
+ const task = getSelectedTask()
676
+ if (!task) {
677
+ inspectorEl.innerHTML = `
678
+ <div class="inspector-empty">
679
+ <p class="section-kicker">Inspector</p>
680
+ <h2>选择任务卡片</h2>
681
+ <p>查看详情、运行记录、审核 diff,或在移动端直接修改状态。</p>
682
+ </div>
683
+ `
684
+ return
685
+ }
686
+
687
+ const taskRuns = getRunsForTask(task.id)
688
+ const latestRun = taskRuns[0] || null
689
+ const labelPills = (task.labels || []).map((label) => `<span class="meta-pill">${escapeHtml(label)}</span>`)
690
+ const dependencyList = task.dependencies?.length
691
+ ? `
692
+ <ul class="file-list">
693
+ ${task.dependencies.map((dependency) => `<li><span class="meta-pill">依赖</span><span>${escapeHtml(dependency)}</span></li>`).join('')}
694
+ </ul>
695
+ `
696
+ : '<p class="inspector-muted">无依赖任务</p>'
697
+
698
+ inspectorEl.innerHTML = `
699
+ <div class="inspector-header">
700
+ <div>
701
+ <p class="section-kicker">${escapeHtml(formatTracker(task.trackerKind))}</p>
702
+ <h2>${escapeHtml(task.title)}</h2>
703
+ <p class="task-meta-line">${escapeHtml(task.id)} · 更新于 ${escapeHtml(formatDate(task.updatedAt))}</p>
704
+ </div>
705
+ <span class="status-pill ${escapeHtml(task.status)}">${escapeHtml(formatTaskStatus(task.status))}</span>
706
+ </div>
707
+
708
+ <section class="inspector-section">
709
+ <div class="inspector-grid">
710
+ <div class="inspector-stat">
711
+ <span>优先级</span>
712
+ <strong>${escapeHtml(formatPriority(task.priority))}</strong>
713
+ </div>
714
+ <div class="inspector-stat">
715
+ <span>任务来源</span>
716
+ <strong>${escapeHtml(formatTracker(task.trackerKind))}</strong>
717
+ </div>
718
+ <div class="inspector-stat">
719
+ <span>运行记录</span>
720
+ <strong>${taskRuns.length}</strong>
721
+ </div>
722
+ <div class="inspector-stat">
723
+ <span>最新运行</span>
724
+ <strong>${escapeHtml(latestRun ? formatRunStatus(latestRun.status) : '暂无')}</strong>
725
+ </div>
726
+ </div>
727
+ </section>
728
+
729
+ <section class="inspector-section">
730
+ <p class="inspector-copy">${escapeHtml(task.description || '暂无说明')}</p>
731
+ <div class="task-card-footer">
732
+ <span class="meta-pill">${escapeHtml(task.type || 'todo')}</span>
733
+ ${task.trackerState ? `<span class="meta-pill">${escapeHtml(task.trackerState)}</span>` : ''}
734
+ ${labelPills.join('')}
735
+ </div>
736
+ <div class="inspector-actions">
737
+ ${task.url ? `<a href="${escapeHtml(task.url)}" target="_blank" rel="noreferrer" class="inline-button" data-action="open-task-link">打开来源任务</a>` : ''}
738
+ </div>
739
+ </section>
740
+
741
+ <section class="inspector-section">
742
+ <p class="section-kicker">Status</p>
743
+ ${
744
+ isLocalTask(task)
745
+ ? `
746
+ <label class="token-label" for="task-status-select">任务状态</label>
747
+ <select id="task-status-select" data-task-status-select data-task-id="${escapeHtml(task.id)}">
748
+ ${renderStatusOptions(task.status)}
749
+ </select>
750
+ <p class="inspector-muted">桌面端可以直接拖卡片改状态;移动端使用这里的下拉切换。</p>
751
+ `
752
+ : `<p class="inspector-muted">远程同步任务在 Web 端只读展示,状态以 ${escapeHtml(formatTracker(task.trackerKind))} 为准。</p>`
753
+ }
754
+ </section>
755
+
756
+ <section class="inspector-section">
757
+ <p class="section-kicker">Dependencies</p>
758
+ ${dependencyList}
759
+ </section>
760
+
761
+ <section class="inspector-section">
762
+ <p class="section-kicker">Runs</p>
763
+ ${
764
+ taskRuns.length
765
+ ? `<div class="run-list">${taskRuns.map((run) => renderRunRecord(run)).join('')}</div>`
766
+ : '<p class="run-empty">暂无运行记录</p>'
767
+ }
768
+ </section>
769
+ `
770
+ }
771
+
772
+ function renderRuns() {
773
+ const visibleRuns = state.runs.slice(0, 12)
774
+ const pendingReviews = state.runs.filter((run) => run.reviewStatus === 'pending').length
775
+ const runningCount = state.runs.filter((run) => run.status === 'running').length
776
+
777
+ runsSummaryEl.textContent = `${runningCount} 运行中 / ${pendingReviews} 待审`
778
+
779
+ if (!visibleRuns.length) {
780
+ runsEl.innerHTML = '<p class="utility-empty">暂无运行记录</p>'
781
+ return
782
+ }
783
+
784
+ runsEl.innerHTML = visibleRuns.map((run) => renderCompactRun(run)).join('')
785
+ }
786
+
787
+ function renderSystemState() {
788
+ const payload = state.runtime || {}
789
+ const runningCount = Array.isArray(payload.state?.running) ? payload.state.running.length : 0
790
+ const trackerName = formatTracker(payload.tracker || 'local')
791
+
792
+ stateSummaryEl.textContent = runningCount > 0 ? `${runningCount} 运行中 · ${trackerName}` : `空闲 · ${trackerName}`
793
+ stateEl.textContent = JSON.stringify(payload, null, 2)
794
+ }
795
+
796
+ function renderLogs() {
797
+ const lines = state.logs.slice(-160).map((entry) => `${entry.at} ${entry.message}`)
798
+ logsSummaryEl.textContent = `${state.logs.length}`
799
+ logsEl.textContent = lines.join('\n')
800
+ }
801
+
802
+ function renderCompactRun(run) {
803
+ const taskId = run.taskId || run.issueIdentifier
804
+ const task = getTaskById(taskId)
805
+ const diffBlock = renderDiffBlock(run)
806
+
807
+ return `
808
+ <article class="compact-run">
809
+ <div class="compact-run-row">
810
+ <div>
811
+ <h3 class="compact-run-title">${escapeHtml(run.issueIdentifier || run.id)}</h3>
812
+ <p class="compact-run-meta">${escapeHtml(run.id)} · ${escapeHtml(formatDate(run.finishedAt || run.startedAt))}</p>
813
+ </div>
814
+ <div class="task-card-footer">
815
+ ${renderRunBadges(run)}
816
+ </div>
817
+ </div>
818
+ <p class="task-meta-line">${escapeHtml(run.summary || 'Run queued')}</p>
819
+ <div class="compact-run-actions">
820
+ ${task ? `<button class="chip-button" data-action data-task-action="select-task" data-task-id="${escapeHtml(task.id)}">查看任务</button>` : ''}
821
+ ${renderRunActionButtons(run, taskId, 'compact')}
822
+ </div>
823
+ ${diffBlock}
824
+ </article>
825
+ `
826
+ }
827
+
828
+ function renderRunRecord(run) {
829
+ const detailLogs = Array.isArray(run.logs) && run.logs.length
830
+ ? `<pre>${escapeHtml(run.logs.join('\n'))}</pre>`
831
+ : '<p class="run-empty">暂无运行日志</p>'
832
+ const diffBlock = renderDiffBlock(run)
833
+ const changedFiles = run.changedFiles?.length
834
+ ? `
835
+ <ul class="file-list">
836
+ ${run.changedFiles.map((file) => `<li><span class="meta-pill">${escapeHtml(file.status)}</span><span>${escapeHtml(file.path)}</span></li>`).join('')}
837
+ </ul>
838
+ `
839
+ : '<p class="run-empty">无文件变更</p>'
840
+
841
+ return `
842
+ <details class="run-record" data-run-detail data-run-id="${escapeHtml(run.id)}" ${state.expandedRuns.has(run.id) ? 'open' : ''}>
843
+ <summary data-run-summary>
844
+ <div class="run-summary-row">
845
+ <div>
846
+ <h3 class="run-summary-title">${escapeHtml(run.id)}</h3>
847
+ <p class="run-meta">${escapeHtml(run.issueIdentifier)} · ${escapeHtml(formatDate(run.finishedAt || run.startedAt))}</p>
848
+ </div>
849
+ <div class="task-card-footer">${renderRunBadges(run)}</div>
850
+ </div>
851
+ </summary>
852
+
853
+ <div class="run-detail-body">
854
+ <p class="task-meta-line">${escapeHtml(run.summary || 'Run queued')}</p>
855
+ ${run.workspacePath ? `<p class="task-meta-line">${escapeHtml(run.workspacePath)}</p>` : ''}
856
+ <div class="run-actions">${renderRunActionButtons(run, run.taskId || run.issueIdentifier, 'full')}</div>
857
+ ${changedFiles}
858
+ ${diffBlock}
859
+ ${detailLogs}
860
+ </div>
861
+ </details>
862
+ `
863
+ }
864
+
865
+ function renderRunActionButtons(run, taskId, variant) {
866
+ const buttons = []
867
+ const diffEntry = state.diffCache[run.id]
868
+ const buttonClass = variant === 'compact' ? 'chip-button' : 'run-action'
869
+
870
+ if (run.status === 'succeeded' && run.reviewStatus === 'pending') {
871
+ buttons.push(`<button class="${buttonClass}${variant === 'full' ? ' primary' : ''}" data-action data-run-action="approve" data-run-id="${escapeHtml(run.id)}" data-task-id="${escapeHtml(taskId)}">应用变更</button>`)
872
+ buttons.push(`<button class="${buttonClass}${variant === 'full' ? ' danger' : ''}" data-action data-run-action="reject" data-run-id="${escapeHtml(run.id)}" data-task-id="${escapeHtml(taskId)}">退回重做</button>`)
873
+ }
874
+
875
+ if (run.status === 'failed') {
876
+ buttons.push(`<button class="${buttonClass}" data-action data-run-action="requeue" data-run-id="${escapeHtml(run.id)}" data-task-id="${escapeHtml(taskId)}">重新排队</button>`)
877
+ }
878
+
879
+ if (run.diffFile) {
880
+ if (diffEntry?.status === 'loading') {
881
+ buttons.push(`<button class="${buttonClass}" disabled>加载中...</button>`)
882
+ } else if (!diffEntry || diffEntry.status === 'error') {
883
+ buttons.push(`<button class="${buttonClass}" data-action data-run-action="load-diff" data-run-id="${escapeHtml(run.id)}" data-task-id="${escapeHtml(taskId)}">加载 Diff</button>`)
884
+ }
885
+ }
886
+
887
+ return buttons.join('')
888
+ }
889
+
890
+ function renderRunBadges(run) {
891
+ const pills = [
892
+ `<span class="status-pill ${escapeHtml(runTone(run.status))}">${escapeHtml(formatRunStatus(run.status))}</span>`,
893
+ `<span class="status-pill ${escapeHtml(reviewTone(run.reviewStatus))}">${escapeHtml(formatReviewStatus(run.reviewStatus))}</span>`,
894
+ `<span class="meta-pill">attempt ${escapeHtml(String(run.attempt || 1))}</span>`
895
+ ]
896
+
897
+ if (run.diffSummary?.total) {
898
+ pills.push(`<span class="meta-pill">${escapeHtml(String(run.diffSummary.total))} files</span>`)
899
+ }
900
+
901
+ if (run.session?.turnCount) {
902
+ pills.push(`<span class="meta-pill">${escapeHtml(String(run.session.turnCount))} turns</span>`)
903
+ }
904
+
905
+ return pills.join('')
906
+ }
907
+
908
+ function renderDiffBlock(run) {
909
+ const diffEntry = state.diffCache[run.id]
910
+ if (!diffEntry) return ''
911
+
912
+ return `<pre class="diff-block">${escapeHtml(diffEntry.text)}</pre>`
913
+ }
914
+
915
+ function renderPriorityOptions(current) {
916
+ return ['high', 'medium', 'low']
917
+ .map((priority) => `<option value="${priority}"${priority === current ? ' selected' : ''}>${escapeHtml(formatPriority(priority))}</option>`)
918
+ .join('')
919
+ }
920
+
921
+ function renderStatusOptions(current) {
922
+ return COLUMNS.map((column) => `<option value="${column.status}"${column.status === current ? ' selected' : ''}>${escapeHtml(column.label)}</option>`).join('')
923
+ }
924
+
925
+ function buildRunsByTask(runs) {
926
+ const map = new Map()
927
+
928
+ for (const run of runs) {
929
+ const taskId = run.taskId || run.issueIdentifier
930
+ if (!map.has(taskId)) map.set(taskId, [])
931
+ map.get(taskId).push(run)
932
+ }
933
+
934
+ return map
935
+ }
936
+
937
+ function getRunsForTask(taskId) {
938
+ return state.runs.filter((run) => (run.taskId || run.issueIdentifier) === taskId)
939
+ }
940
+
941
+ function sortRuns(runs) {
942
+ return [...runs].sort((left, right) => {
943
+ return toEpoch(right.finishedAt || right.startedAt) - toEpoch(left.finishedAt || left.startedAt)
944
+ })
945
+ }
946
+
947
+ function sortTasksForBoard(tasks) {
948
+ return [...tasks].sort((left, right) => {
949
+ const priorityDelta = (PRIORITY_ORDER[left.priority] ?? 9) - (PRIORITY_ORDER[right.priority] ?? 9)
950
+ if (priorityDelta !== 0) return priorityDelta
951
+
952
+ const dateDelta = toEpoch(right.updatedAt) - toEpoch(left.updatedAt)
953
+ if (dateDelta !== 0) return dateDelta
954
+
955
+ return COLLATOR.compare(left.title || '', right.title || '')
956
+ })
957
+ }
958
+
959
+ function getTaskById(taskId) {
960
+ return state.tasks.find((task) => task.id === taskId) || null
961
+ }
962
+
963
+ function getSelectedTask() {
964
+ return getTaskById(state.selectedTaskId)
965
+ }
966
+
967
+ function getTrackerMode() {
968
+ return state.runtime?.tracker || 'local'
969
+ }
970
+
971
+ function canCreateTasks() {
972
+ return getTrackerMode() === 'local'
973
+ }
974
+
975
+ function isLocalTask(task) {
976
+ return task?.trackerKind === 'local'
977
+ }
978
+
979
+ function canMoveTask(task, nextStatus) {
980
+ return Boolean(task && isLocalTask(task) && STATUS_LABELS[nextStatus] && task.status !== nextStatus)
981
+ }
982
+
983
+ function buildTaskMetaLine(task, latestRun) {
984
+ const parts = []
985
+
986
+ if (task.url) parts.push('已关联来源任务')
987
+ if (task.dependencies?.length) parts.push(`依赖 ${task.dependencies.join(', ')}`)
988
+ if (latestRun?.reviewStatus === 'pending') parts.push('有待审核变更')
989
+ if (!parts.length) parts.push(isLocalTask(task) ? '本地任务,可直接拖拽流转' : '远程任务,只读同步展示')
990
+
991
+ return parts.join(' · ')
992
+ }
993
+
994
+ function highlightColumn(columnEl) {
995
+ clearDropTargets()
996
+ columnEl.classList.add('is-drop-target')
997
+ }
998
+
999
+ function clearDropTargets() {
1000
+ boardEl.querySelectorAll('.kanban-column.is-drop-target').forEach((column) => column.classList.remove('is-drop-target'))
1001
+ }
1002
+
1003
+ async function apiFetch(url, options = {}) {
1004
+ const response = await fetchWithAuth(url, options)
1005
+
1006
+ if (response.status === 401) {
1007
+ throw new Error('未授权,请填写正确的 API Token')
1008
+ }
1009
+
1010
+ if (!response.ok) {
1011
+ const payload = await safeJson(response)
1012
+ throw new Error(payload.error || `Request failed: ${response.status}`)
1013
+ }
1014
+
1015
+ return response.json()
1016
+ }
1017
+
1018
+ function fetchWithAuth(url, options = {}) {
1019
+ const headers = new Headers(options.headers || {})
1020
+ const token = localStorage.getItem(TOKEN_KEY)
1021
+
1022
+ if (token) {
1023
+ headers.set('x-taskode-token', token)
1024
+ }
1025
+
1026
+ if (options.body && !headers.has('Content-Type')) {
1027
+ headers.set('Content-Type', 'application/json')
1028
+ }
1029
+
1030
+ return fetch(url, { ...options, headers })
1031
+ }
1032
+
1033
+ async function safeJson(response) {
1034
+ try {
1035
+ return await response.json()
1036
+ } catch {
1037
+ return { error: 'Request failed' }
1038
+ }
1039
+ }
1040
+
1041
+ function parseDependencies(value) {
1042
+ return String(value || '')
1043
+ .split(',')
1044
+ .map((entry) => entry.trim())
1045
+ .filter(Boolean)
1046
+ }
1047
+
1048
+ function formatTaskStatus(status) {
1049
+ return STATUS_LABELS[status] || status || '未知状态'
1050
+ }
1051
+
1052
+ function formatPriority(priority) {
1053
+ return PRIORITY_LABELS[priority] || priority || '未设置'
1054
+ }
1055
+
1056
+ function formatTracker(tracker) {
1057
+ return TRACKER_LABELS[tracker] || tracker || '未知来源'
1058
+ }
1059
+
1060
+ function formatReviewStatus(status) {
1061
+ return REVIEW_LABELS[status] || status || '未知'
1062
+ }
1063
+
1064
+ function formatRunStatus(status) {
1065
+ return RUN_LABELS[status] || status || '未知'
1066
+ }
1067
+
1068
+ function runTone(status) {
1069
+ if (status === 'running' || status === 'queued') return 'in_progress'
1070
+ if (status === 'succeeded') return 'done'
1071
+ if (status === 'failed') return 'failed'
1072
+ return 'cancelled'
1073
+ }
1074
+
1075
+ function reviewTone(status) {
1076
+ if (status === 'pending') return 'review'
1077
+ if (status === 'approved') return 'done'
1078
+ if (status === 'rejected') return 'failed'
1079
+ return 'todo'
1080
+ }
1081
+
1082
+ function toEpoch(value) {
1083
+ const time = Date.parse(value || '')
1084
+ return Number.isNaN(time) ? 0 : time
1085
+ }
1086
+
1087
+ function formatDate(value) {
1088
+ const time = toEpoch(value)
1089
+ if (!time) return '未知时间'
1090
+
1091
+ return new Intl.DateTimeFormat('zh-CN', {
1092
+ month: '2-digit',
1093
+ day: '2-digit',
1094
+ hour: '2-digit',
1095
+ minute: '2-digit'
1096
+ }).format(time)
1097
+ }
1098
+
1099
+ function updateAuthStatus(message, isError = false) {
1100
+ authStatusEl.textContent = message || '鉴权通过时,Dashboard 会自动附带 x-taskode-token。'
1101
+ authStatusEl.style.color = isError ? 'var(--danger)' : ''
1102
+ }
1103
+
1104
+ function escapeHtml(value) {
1105
+ return String(value || '')
1106
+ .replaceAll('&', '&amp;')
1107
+ .replaceAll('<', '&lt;')
1108
+ .replaceAll('>', '&gt;')
1109
+ .replaceAll('"', '&quot;')
1110
+ }