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/README.md +398 -0
- package/WORKFLOW.md +53 -0
- package/bin/taskode.js +7 -0
- package/package.json +30 -0
- package/public/app.js +1110 -0
- package/public/index.html +101 -0
- package/public/styles.css +821 -0
- package/src/app-server.js +555 -0
- package/src/auth.js +13 -0
- package/src/cli.js +176 -0
- package/src/orchestrator.js +655 -0
- package/src/path-safety.js +80 -0
- package/src/policy.js +23 -0
- package/src/review.js +197 -0
- package/src/runner.js +133 -0
- package/src/server.js +168 -0
- package/src/ssh.js +82 -0
- package/src/store.js +355 -0
- package/src/tracker/github.js +143 -0
- package/src/tracker/index.js +71 -0
- package/src/tracker/linear.js +229 -0
- package/src/tracker/local.js +75 -0
- package/src/workflow-store.js +77 -0
- package/src/workflow.js +339 -0
- package/src/workspace.js +291 -0
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('&', '&')
|
|
1107
|
+
.replaceAll('<', '<')
|
|
1108
|
+
.replaceAll('>', '>')
|
|
1109
|
+
.replaceAll('"', '"')
|
|
1110
|
+
}
|