dsh-taskboard 0.3.3 → 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 +22 -6
- package/lib/client.js +1192 -39
- package/lib/host/execution.js +6 -1
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +95 -2
- package/lib/host/git.js.map +1 -1
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +184 -2
- package/lib/host/routes.js.map +1 -1
- package/lib/host/store.js +12 -0
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +166 -0
- package/lib/host/templates.js.map +1 -0
- package/lib/host/tools.js +202 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +7 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +277 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +28 -0
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +45 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +192 -8
- package/src/client/board/TaskFormModal.tsx +100 -18
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +152 -8
- package/src/client/styles.ts +153 -0
- package/src/host/execution.ts +10 -2
- package/src/host/git.ts +77 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +215 -0
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +198 -2
- package/src/index.ts +6 -0
- package/src/shared/api.ts +54 -0
- package/src/shared/protocol.ts +344 -0
- package/src/shared/version.ts +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ledger-import modal (0.4.0): pick a JSON file → dry-run preview
|
|
3
|
+
* (create / overwrite / invalid classification) → commit as merge or
|
|
4
|
+
* replace. Replace swaps the WHOLE ledger after an automatic backup and a
|
|
5
|
+
* double confirmation. Files exported by ⬇ JSON import as-is.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-taskboard/client/board/ImportModal
|
|
8
|
+
*/
|
|
9
|
+
import { useRef, useState } from 'react'
|
|
10
|
+
import type { BoardController } from '../controller.ts'
|
|
11
|
+
import type { ImportPreviewResponse } from '../../shared/api.ts'
|
|
12
|
+
import { useAlert } from './AlertModal.tsx'
|
|
13
|
+
|
|
14
|
+
/** One classified row (create / overwrite). */
|
|
15
|
+
function PlanRow({ row }: { row: { id: string; title: string; status: string } }) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="dsh-atb-imp-row" title={row.id}>
|
|
18
|
+
<span className="dsh-atb-imp-row-title">{row.title}</span>
|
|
19
|
+
<span className="dsh-atb-imp-row-status">{row.status}</span>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The import modal.
|
|
26
|
+
* @param controller - the controller.
|
|
27
|
+
*/
|
|
28
|
+
export function ImportModal({ controller }: { controller: BoardController }) {
|
|
29
|
+
const [fileName, setFileName] = useState('')
|
|
30
|
+
const [parsed, setParsed] = useState<unknown>(null)
|
|
31
|
+
const [parseError, setParseError] = useState<string | undefined>(undefined)
|
|
32
|
+
const [plan, setPlan] = useState<ImportPreviewResponse['plan'] | undefined>(undefined)
|
|
33
|
+
const [mode, setMode] = useState<'merge' | 'replace'>('merge')
|
|
34
|
+
const [busy, setBusy] = useState(false)
|
|
35
|
+
const [result, setResult] = useState<string | undefined>(undefined)
|
|
36
|
+
const [confirmReplace, setConfirmReplace] = useState(false)
|
|
37
|
+
const fileRef = useRef<HTMLInputElement>(null)
|
|
38
|
+
const { alert: showAlert, el: alertEl } = useAlert()
|
|
39
|
+
|
|
40
|
+
/** Read + parse the picked file, then dry-run the preview. */
|
|
41
|
+
const onFile = (file: File | undefined): void => {
|
|
42
|
+
setPlan(undefined)
|
|
43
|
+
setParseError(undefined)
|
|
44
|
+
setResult(undefined)
|
|
45
|
+
setConfirmReplace(false)
|
|
46
|
+
setFileName('')
|
|
47
|
+
setParsed(null)
|
|
48
|
+
if (file === undefined) return
|
|
49
|
+
void file.text().then(text => {
|
|
50
|
+
try {
|
|
51
|
+
const value: unknown = JSON.parse(text)
|
|
52
|
+
setParsed(value)
|
|
53
|
+
setFileName(file.name)
|
|
54
|
+
void controller.importPreview(value).then(p => {
|
|
55
|
+
if (p !== undefined) setPlan(p)
|
|
56
|
+
})
|
|
57
|
+
} catch {
|
|
58
|
+
setParseError('文件不是合法 JSON')
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Commit the import (replace requires the inline double confirmation). */
|
|
64
|
+
const commit = (): void => {
|
|
65
|
+
if (parsed === null || plan === undefined || busy) return
|
|
66
|
+
if (mode === 'replace' && !confirmReplace) {
|
|
67
|
+
setConfirmReplace(true)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
setBusy(true)
|
|
71
|
+
void controller.importCommit(mode, parsed).then(r => {
|
|
72
|
+
setBusy(false)
|
|
73
|
+
setConfirmReplace(false)
|
|
74
|
+
if (r === undefined) return
|
|
75
|
+
setResult(r.mode === 'replace'
|
|
76
|
+
? `整册替换完成:导入 ${r.created + r.overwritten} 张(原 ${r.replacedTotal} 张已整册备份)`
|
|
77
|
+
: `合并完成:新增 ${r.created} 张、覆盖 ${r.overwritten} 张`)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const close = (): void => controller.closeImport()
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close() }}>
|
|
85
|
+
<div className="dsh-atb-modal dsh-atb-imp" role="dialog" aria-modal="true" aria-label="导入台账">
|
|
86
|
+
<div className="dsh-atb-modal-head">
|
|
87
|
+
<span className="dsh-atb-modal-headicon">⬆</span>
|
|
88
|
+
<div className="dsh-atb-modal-headtext">
|
|
89
|
+
<h3>导入台账</h3>
|
|
90
|
+
<p>选择导出的 JSON 备份文件:先预览、再合并或整册替换</p>
|
|
91
|
+
</div>
|
|
92
|
+
<button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={close}>✕</button>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="dsh-atb-modal-body">
|
|
95
|
+
<div className="dsh-atb-imp-picker">
|
|
96
|
+
<input
|
|
97
|
+
ref={fileRef}
|
|
98
|
+
type="file"
|
|
99
|
+
accept=".json,application/json"
|
|
100
|
+
onChange={e => onFile(e.target.files?.[0])}
|
|
101
|
+
/>
|
|
102
|
+
{fileName.length > 0 && <span className="dsh-atb-imp-filename">{fileName}</span>}
|
|
103
|
+
</div>
|
|
104
|
+
<div className="dsh-atb-imp-note">⬇ JSON 导出的文件即为同格式备份,可直接导入恢复;导入文件的 schemaVersion 必须与当前版本一致。</div>
|
|
105
|
+
|
|
106
|
+
{parseError !== undefined && <div className="dsh-atb-imp-error">{parseError}</div>}
|
|
107
|
+
{plan === undefined && parseError === undefined && fileName.length > 0 && <div className="dsh-atb-empty2">预览中…</div>}
|
|
108
|
+
|
|
109
|
+
{plan !== undefined && (
|
|
110
|
+
<>
|
|
111
|
+
<div className="dsh-atb-imp-stats">
|
|
112
|
+
<div className="dsh-atb-imp-stat" data-tone="ok"><b>{plan.create.length}</b><span>新增</span></div>
|
|
113
|
+
<div className="dsh-atb-imp-stat" data-tone="warn"><b>{plan.overwrite.length}</b><span>覆盖(同 id)</span></div>
|
|
114
|
+
<div className="dsh-atb-imp-stat" data-tone={plan.invalid.length > 0 ? 'bad' : undefined}><b>{plan.invalid.length}</b><span>无效(跳过)</span></div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{plan.create.length > 0 && (
|
|
118
|
+
<div className="dsh-atb-imp-sec">
|
|
119
|
+
<h4>新增任务</h4>
|
|
120
|
+
<div className="dsh-atb-imp-list">{plan.create.map(r => <PlanRow key={r.id} row={r} />)}</div>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
{plan.overwrite.length > 0 && (
|
|
124
|
+
<div className="dsh-atb-imp-sec">
|
|
125
|
+
<h4>覆盖任务(整卡替换,含执行历史与评论)</h4>
|
|
126
|
+
<div className="dsh-atb-imp-list">{plan.overwrite.map(r => <PlanRow key={r.id} row={r} />)}</div>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
{plan.invalid.length > 0 && (
|
|
130
|
+
<div className="dsh-atb-imp-sec">
|
|
131
|
+
<h4>无效条目(不会导入)</h4>
|
|
132
|
+
<div className="dsh-atb-imp-list">
|
|
133
|
+
{plan.invalid.map((r, i) => (
|
|
134
|
+
<div key={r.id ?? `invalid-${i}`} className="dsh-atb-imp-row" data-tone="bad" title={r.id ?? ''}>
|
|
135
|
+
<span className="dsh-atb-imp-row-title">{r.id ?? '(无 id)'}</span>
|
|
136
|
+
<span className="dsh-atb-imp-row-status">{r.reason}</span>
|
|
137
|
+
</div>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<div className="dsh-atb-mode-picker">
|
|
144
|
+
<button type="button" className="dsh-atb-mode-opt" data-on={mode === 'merge'} onClick={() => { setMode('merge'); setConfirmReplace(false) }}>
|
|
145
|
+
<span className="dsh-atb-mode-name">⊕ 合并</span>
|
|
146
|
+
<span className="dsh-atb-mode-hint">新增 + 按 id 覆盖,其余不动</span>
|
|
147
|
+
</button>
|
|
148
|
+
<button type="button" className="dsh-atb-mode-opt" data-on={mode === 'replace'} onClick={() => setMode('replace')}>
|
|
149
|
+
<span className="dsh-atb-mode-name">💣 整册替换</span>
|
|
150
|
+
<span className="dsh-atb-mode-hint">清空当前台账,以导入文件为准(先自动备份)</span>
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{result !== undefined && <div className="dsh-atb-imp-result">{result}</div>}
|
|
155
|
+
</>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
<div className="dsh-atb-modal-foot">
|
|
159
|
+
<span className="dsh-atb-modal-hint">
|
|
160
|
+
{mode === 'replace'
|
|
161
|
+
? confirmReplace ? '⚠ 再次点击确认执行整册替换(不可撤销,已自动备份)' : '整册替换需要二次确认'
|
|
162
|
+
: '合并只写入预览中列出的任务'}
|
|
163
|
+
</span>
|
|
164
|
+
<span className="dsh-atb-modal-footbtns">
|
|
165
|
+
<button type="button" className="dsh-atb-btn" onClick={close}>{result !== undefined ? '关闭' : '取消'}</button>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
className="dsh-atb-btn"
|
|
169
|
+
data-primary="true"
|
|
170
|
+
data-danger={mode === 'replace' && confirmReplace ? 'true' : undefined}
|
|
171
|
+
disabled={plan === undefined || busy || (result !== undefined && false)}
|
|
172
|
+
onClick={commit}
|
|
173
|
+
>
|
|
174
|
+
{mode === 'replace' && confirmReplace ? '确认整册替换' : '执行导入'}
|
|
175
|
+
</button>
|
|
176
|
+
</span>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
{alertEl}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -12,6 +12,8 @@ import { PLUGIN_VERSION } from '../../shared/version.ts'
|
|
|
12
12
|
import { DRAG_TYPE, TaskCard } from './TaskCard.tsx'
|
|
13
13
|
import { TaskDetail } from './TaskDetail.tsx'
|
|
14
14
|
import { TaskFormModal } from './TaskFormModal.tsx'
|
|
15
|
+
import { ImportModal } from './ImportModal.tsx'
|
|
16
|
+
import { TemplateManager } from './TemplateManager.tsx'
|
|
15
17
|
import { useAlert } from './AlertModal.tsx'
|
|
16
18
|
|
|
17
19
|
/** Column labels. */
|
|
@@ -85,15 +87,50 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
85
87
|
const live = filterTasks(state, state.ledger.tasks.filter(t => t.trashedAt === undefined))
|
|
86
88
|
const selected = state.selectedId === undefined ? undefined : state.ledger.tasks.find(t => t.id === state.selectedId)
|
|
87
89
|
const { alert: showAlert, el: alertEl } = useAlert()
|
|
90
|
+
// + 新建任务 ▼ dropdown (0.4.0): blank / templates / manage / import.
|
|
91
|
+
const [newMenuOpen, setNewMenuOpen] = useState(false)
|
|
92
|
+
const closeMenu = (): void => setNewMenuOpen(false)
|
|
88
93
|
|
|
89
94
|
return (
|
|
90
95
|
<div className="dsh-atb-board">
|
|
91
96
|
<div className="dsh-atb-toolbar">
|
|
92
97
|
<h2 className="dsh-atb-title">Agent 任务看板</h2>
|
|
93
98
|
<span className="dsh-atb-count">{live.length} 任务 · rev {state.ledger.revision}</span>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
<div className="dsh-atb-newmenu">
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
className="dsh-atb-btn"
|
|
103
|
+
data-primary="true"
|
|
104
|
+
onClick={() => {
|
|
105
|
+
const next = !newMenuOpen
|
|
106
|
+
setNewMenuOpen(next)
|
|
107
|
+
if (next) controller.prepareTemplateMenu()
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
+ 新建任务 ▼
|
|
111
|
+
</button>
|
|
112
|
+
{newMenuOpen && (
|
|
113
|
+
<>
|
|
114
|
+
<div className="dsh-atb-newmenu-backdrop" onClick={closeMenu} />
|
|
115
|
+
<div className="dsh-atb-newmenu-list">
|
|
116
|
+
<button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.setComposer(true) }}>空白任务</button>
|
|
117
|
+
{state.templates.map(t => (
|
|
118
|
+
<button
|
|
119
|
+
key={t.id}
|
|
120
|
+
type="button"
|
|
121
|
+
className="dsh-atb-newmenu-opt"
|
|
122
|
+
title={t.task.description !== undefined && t.task.description.length > 0 ? t.task.description.slice(0, 120) : t.name}
|
|
123
|
+
onClick={() => { closeMenu(); controller.newFromTemplate(t.task) }}
|
|
124
|
+
>
|
|
125
|
+
{t.name}{t.builtin === true ? '' : ''}
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
<div className="dsh-atb-newmenu-sep" />
|
|
129
|
+
<button type="button" className="dsh-atb-newmenu-opt" onClick={() => { closeMenu(); controller.openTemplateManager() }}>⌗ 管理模板…</button>
|
|
130
|
+
</div>
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
97
134
|
<div className="dsh-atb-spacer" />
|
|
98
135
|
<input
|
|
99
136
|
className="dsh-atb-input dsh-atb-search"
|
|
@@ -138,6 +175,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
138
175
|
{state.secondaryOpen ? '返回看板' : '其它任务'}
|
|
139
176
|
</button>
|
|
140
177
|
<button type="button" className="dsh-atb-btn" title="健康诊断:遗留 worktree、台账基本项" onClick={() => controller.openDiagnostics()}>⚙ 诊断</button>
|
|
178
|
+
<button type="button" className="dsh-atb-btn" title="从 JSON 备份文件导入台账(预览后合并或整册替换)" onClick={() => controller.openImport()}>⬆ 导入</button>
|
|
141
179
|
<button type="button" className="dsh-atb-btn" title="下载完整台账备份(JSON)" onClick={() => controller.exportJson()}>⬇ JSON</button>
|
|
142
180
|
<button type="button" className="dsh-atb-btn" title="下载任务清单(CSV)" onClick={() => controller.exportCsv()}>⬇ CSV</button>
|
|
143
181
|
<a
|
|
@@ -223,6 +261,10 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
223
261
|
|
|
224
262
|
{state.diagOpen && <DiagnosticsPanel controller={controller} />}
|
|
225
263
|
|
|
264
|
+
{state.importOpen && <ImportModal controller={controller} />}
|
|
265
|
+
|
|
266
|
+
{state.tplManagerOpen && <TemplateManager controller={controller} />}
|
|
267
|
+
|
|
226
268
|
{alertEl}
|
|
227
269
|
</div>
|
|
228
270
|
)
|
|
@@ -86,6 +86,15 @@ export function TaskCard({ task, controller, draggable = false, now, onAlert }:
|
|
|
86
86
|
<span className="dsh-atb-badge" data-kind="scheduled">⏰ {fmtTime(task.execution.nextRunAt)}</span>
|
|
87
87
|
)}
|
|
88
88
|
{task.model !== undefined && <span className="dsh-atb-badge">{task.model.model}</span>}
|
|
89
|
+
{task.checklist !== undefined && task.checklist.length > 0 && (
|
|
90
|
+
<span
|
|
91
|
+
className="dsh-atb-badge"
|
|
92
|
+
data-kind={task.status === 'in_review' && task.checklist.some(i => !i.checked) ? 'blocked' : 'checklist'}
|
|
93
|
+
title={task.status === 'in_review' && task.checklist.some(i => !i.checked) ? '待验收:清单未全部勾选' : '验收清单进度'}
|
|
94
|
+
>
|
|
95
|
+
☑ {task.checklist.filter(i => i.checked).length}/{task.checklist.length}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
89
98
|
{task.status === 'done' && <span className="dsh-atb-badge" data-kind="done">完成</span>}
|
|
90
99
|
{last !== undefined && (
|
|
91
100
|
<span className="dsh-atb-badge" data-kind={last.outcome === 'running' ? 'running' : last.outcome}>
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module dsh-taskboard/client/board/TaskDetail
|
|
9
9
|
*/
|
|
10
|
-
import { useState, type ReactNode } from 'react'
|
|
10
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
11
11
|
import type { BoardController } from '../controller.ts'
|
|
12
12
|
import type { ExecutionRecord, TaskRecord } from '../../shared/protocol.ts'
|
|
13
|
-
import { canTransition } from '../../shared/protocol.ts'
|
|
13
|
+
import { canTransition, checklistProgress } from '../../shared/protocol.ts'
|
|
14
14
|
import { useAlert } from './AlertModal.tsx'
|
|
15
15
|
import { fmtTime, isStaleClaim } from './TaskBoard.tsx'
|
|
16
16
|
|
|
@@ -58,6 +58,129 @@ function shortHash(hash: string | undefined): string {
|
|
|
58
58
|
return hash === undefined ? '' : hash.slice(0, 8)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Extract the path from one `git status --porcelain` line (rename-aware). */
|
|
62
|
+
function porcelainPath(line: string): string {
|
|
63
|
+
let p = line.slice(3)
|
|
64
|
+
const arrow = p.indexOf(' -> ')
|
|
65
|
+
if (arrow >= 0) p = p.slice(arrow + 4)
|
|
66
|
+
if (p.startsWith('"') && p.endsWith('"') && p.length > 1) p = p.slice(1, -1)
|
|
67
|
+
return p
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lazy diff viewer (0.4.0): loads on mount, renders inside a capped <pre>.
|
|
72
|
+
* @param spec - what to show: one commit hash, or one changed path.
|
|
73
|
+
*/
|
|
74
|
+
function DiffView({ controller, task, execution, commit, path }: {
|
|
75
|
+
controller: BoardController
|
|
76
|
+
task: TaskRecord
|
|
77
|
+
execution: ExecutionRecord
|
|
78
|
+
commit?: string
|
|
79
|
+
path?: string
|
|
80
|
+
}) {
|
|
81
|
+
const [state, setState] = useState<{ loading: boolean; diff?: string; truncated?: boolean; failed?: boolean }>({ loading: true })
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
let alive = true
|
|
84
|
+
setState({ loading: true })
|
|
85
|
+
void controller.fetchDiff(task.id, { execution: execution.id, ...(commit !== undefined ? { commit } : { path: path ?? '' }) }).then(result => {
|
|
86
|
+
if (!alive) return
|
|
87
|
+
if (result === undefined) setState({ loading: false, failed: true })
|
|
88
|
+
else setState({ loading: false, diff: result.diff, truncated: result.truncated })
|
|
89
|
+
})
|
|
90
|
+
return () => { alive = false }
|
|
91
|
+
}, [controller, task.id, execution.id, commit, path])
|
|
92
|
+
return (
|
|
93
|
+
<div className="dsh-atb-diffview">
|
|
94
|
+
<div className="dsh-atb-diffview-head">
|
|
95
|
+
<span className="dsh-atb-diffview-title">{commit !== undefined ? `提交 ${shortHash(commit)}` : `文件 ${path}`}</span>
|
|
96
|
+
{state.loading && <span className="dsh-atb-diffview-hint">读取中…</span>}
|
|
97
|
+
{state.truncated === true && <span className="dsh-atb-diffview-hint">⚠ 内容过长已截断</span>}
|
|
98
|
+
</div>
|
|
99
|
+
{state.failed === true
|
|
100
|
+
? <div className="dsh-atb-diffview-error">获取失败(原因见看板顶部错误条;对象可能已随 worktree 删除丢失)</div>
|
|
101
|
+
: <pre className="dsh-atb-diffview-pre">{state.diff ?? ''}</pre>}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The DoD checklist block (0.4.0): user-togglable items, checker + evidence
|
|
108
|
+
* per row; unchecked items highlight while the task sits in in_review.
|
|
109
|
+
*/
|
|
110
|
+
function ChecklistBlock({ task, controller }: { task: TaskRecord; controller: BoardController }) {
|
|
111
|
+
const items = task.checklist ?? []
|
|
112
|
+
if (items.length === 0) return null
|
|
113
|
+
const { done, total } = checklistProgress(task)
|
|
114
|
+
const unchecked = total - done
|
|
115
|
+
const reviewing = task.status === 'in_review'
|
|
116
|
+
return (
|
|
117
|
+
<div className="dsh-atb-fieldcard" data-kind="checklist">
|
|
118
|
+
<div className="dsh-atb-fieldcard-label">
|
|
119
|
+
验收清单(DoD)
|
|
120
|
+
<span className="dsh-atb-cl-progress" data-tone={reviewing && unchecked > 0 ? 'bad' : undefined}>
|
|
121
|
+
☑ {done}/{total}{reviewing && unchecked > 0 ? ` · ${unchecked} 项未完成` : done === total ? ' · 全部完成' : ''}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="dsh-atb-cl-items">
|
|
125
|
+
{items.map(item => (
|
|
126
|
+
<label
|
|
127
|
+
key={item.id}
|
|
128
|
+
className="dsh-atb-cl-item"
|
|
129
|
+
data-checked={item.checked ? 'true' : undefined}
|
|
130
|
+
data-alert={reviewing && !item.checked ? 'true' : undefined}
|
|
131
|
+
>
|
|
132
|
+
<input
|
|
133
|
+
type="checkbox"
|
|
134
|
+
checked={item.checked}
|
|
135
|
+
onChange={() => void controller.toggleChecklistItem(task, item.id)}
|
|
136
|
+
/>
|
|
137
|
+
<span className="dsh-atb-cl-text">{item.text}</span>
|
|
138
|
+
<span className="dsh-atb-cl-meta">
|
|
139
|
+
{item.checked
|
|
140
|
+
? `${item.checkedBy === 'user' ? '👤 用户' : `🤖 ${shortId(item.checkedBy)}`} · ${fmtTime(item.checkedAt)}`
|
|
141
|
+
: '未完成'}
|
|
142
|
+
{item.note !== undefined && item.note.length > 0 && <span className="dsh-atb-cl-note" title={item.note}>证据:{item.note}</span>}
|
|
143
|
+
</span>
|
|
144
|
+
</label>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The structured execution report block (0.4.0): the newest execution that
|
|
153
|
+
* carries one, rendered section by section for the reviewer.
|
|
154
|
+
*/
|
|
155
|
+
function ReportBlock({ task }: { task: TaskRecord }) {
|
|
156
|
+
const execution = [...task.executions].reverse().find(e => e.report !== undefined)
|
|
157
|
+
const report = execution?.report
|
|
158
|
+
if (execution === undefined || report === undefined) return null
|
|
159
|
+
const section = (label: string, rows: string[] | undefined): ReactNode => rows !== undefined && rows.length > 0
|
|
160
|
+
? (
|
|
161
|
+
<div className="dsh-atb-rpt-sec">
|
|
162
|
+
<div className="dsh-atb-rpt-label">{label}</div>
|
|
163
|
+
<ul className="dsh-atb-rpt-list">{rows.map((row, i) => <li key={i}>{row}</li>)}</ul>
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
: null
|
|
167
|
+
return (
|
|
168
|
+
<div className="dsh-atb-fieldcard" data-kind="report">
|
|
169
|
+
<div className="dsh-atb-fieldcard-label">执行报告<span className="dsh-atb-cl-progress">由执行会话提交 · {fmtTime(execution.endedAt ?? execution.startedAt)}</span></div>
|
|
170
|
+
<div className="dsh-atb-rpt-summary">{report.summary}</div>
|
|
171
|
+
{section('改动文件', report.changedFiles)}
|
|
172
|
+
{section('自验情况', report.checks)}
|
|
173
|
+
{section('产物', report.artifacts)}
|
|
174
|
+
{report.risk.length > 0 && (
|
|
175
|
+
<div className="dsh-atb-rpt-sec">
|
|
176
|
+
<div className="dsh-atb-rpt-label">剩余风险</div>
|
|
177
|
+
<div className="dsh-atb-rpt-risk">{report.risk}</div>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
61
184
|
/**
|
|
62
185
|
* The 0.3.0 isolation block: branch / baseline→head commits / change stats /
|
|
63
186
|
* uncommitted-changes warning, plus the user-only git actions (merge /
|
|
@@ -68,6 +191,9 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
|
|
|
68
191
|
const [confirmMerge, setConfirmMerge] = useState(false)
|
|
69
192
|
const [confirmRemove, setConfirmRemove] = useState<'wt' | 'wtb' | null>(null)
|
|
70
193
|
const [busy, setBusy] = useState(false)
|
|
194
|
+
// Diff viewer (0.4.0): which commit / changed path is expanded.
|
|
195
|
+
const [openDiff, setOpenDiff] = useState<{ commit?: string; path?: string } | null>(null)
|
|
196
|
+
const [dirtyOpen, setDirtyOpen] = useState(false)
|
|
71
197
|
const execution = latestIsolated(task)
|
|
72
198
|
const running = task.executions.some(e => e.outcome === 'running')
|
|
73
199
|
if (execution === undefined) return null
|
|
@@ -124,9 +250,19 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
|
|
|
124
250
|
? (
|
|
125
251
|
<div className="dsh-atb-iso-commits">
|
|
126
252
|
{commits.slice(0, 10).map(c => (
|
|
127
|
-
<div key={c.hash} className="dsh-atb-iso-commit">
|
|
128
|
-
<
|
|
129
|
-
|
|
253
|
+
<div key={c.hash} className="dsh-atb-iso-commit" data-open={openDiff?.commit === c.hash ? 'true' : undefined}>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
className="dsh-atb-iso-commit-btn"
|
|
257
|
+
title="点击展开该提交的 diff"
|
|
258
|
+
onClick={() => setOpenDiff(openDiff?.commit === c.hash ? null : { commit: c.hash })}
|
|
259
|
+
>
|
|
260
|
+
<code>{shortHash(c.hash)}</code>
|
|
261
|
+
<span>{c.subject}</span>
|
|
262
|
+
</button>
|
|
263
|
+
{openDiff?.commit === c.hash && (
|
|
264
|
+
<DiffView controller={controller} task={task} execution={execution} commit={c.hash} />
|
|
265
|
+
)}
|
|
130
266
|
</div>
|
|
131
267
|
))}
|
|
132
268
|
{commitTotal > 10 && <div className="dsh-atb-iso-more">… 共 {commitTotal} 个提交</div>}
|
|
@@ -135,8 +271,32 @@ function IsolationBlock({ task, controller }: { task: TaskRecord; controller: Bo
|
|
|
135
271
|
: <div className="dsh-atb-iso-nocommit">该次执行没有产生提交(改动可能未提交,见下方警告)</div>}
|
|
136
272
|
|
|
137
273
|
{dirtyTotal > 0 && (
|
|
138
|
-
<div className="dsh-atb-iso-dirty"
|
|
139
|
-
|
|
274
|
+
<div className="dsh-atb-iso-dirty">
|
|
275
|
+
<button type="button" className="dsh-atb-iso-dirty-toggle" onClick={() => setDirtyOpen(!dirtyOpen)}>
|
|
276
|
+
⚠ 有 {dirtyTotal} 处未提交修改(合并前请让 agent 提交,或手动处理){dirtyOpen ? ' ▲' : ' ▼ 查看文件'}
|
|
277
|
+
</button>
|
|
278
|
+
{dirtyOpen && (
|
|
279
|
+
<div className="dsh-atb-iso-dirty-files">
|
|
280
|
+
{dirty.slice(0, 30).map((line, index) => {
|
|
281
|
+
const filePath = porcelainPath(line)
|
|
282
|
+
return (
|
|
283
|
+
<button
|
|
284
|
+
key={`${line}-${index}`}
|
|
285
|
+
type="button"
|
|
286
|
+
className="dsh-atb-iso-dirty-file"
|
|
287
|
+
title="点击查看该文件的未提交 diff"
|
|
288
|
+
onClick={() => setOpenDiff(openDiff?.path === filePath ? null : { path: filePath })}
|
|
289
|
+
>
|
|
290
|
+
<code>{line.slice(0, 2)}</code> {filePath}
|
|
291
|
+
</button>
|
|
292
|
+
)
|
|
293
|
+
})}
|
|
294
|
+
{dirtyTotal > 30 && <div className="dsh-atb-iso-more">… 共 {dirtyTotal} 处(完整列表见任务台账)</div>}
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
{openDiff?.path !== undefined && dirtyOpen && (
|
|
298
|
+
<DiffView controller={controller} task={task} execution={execution} path={openDiff.path} />
|
|
299
|
+
)}
|
|
140
300
|
</div>
|
|
141
301
|
)}
|
|
142
302
|
|
|
@@ -220,6 +380,7 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
220
380
|
const runningExecution = task.executions.find(e => e.outcome === 'running')
|
|
221
381
|
const holder = task.status === 'in_progress' ? task.claimedBy : undefined
|
|
222
382
|
const stale = now !== undefined && isStaleClaim(task, now)
|
|
383
|
+
const unchecked = (task.checklist ?? []).filter(i => !i.checked).length
|
|
223
384
|
|
|
224
385
|
/** Jump to an execution's session; prompt precisely when it cannot open. */
|
|
225
386
|
const jumpToSession = (sessionId: string): void => {
|
|
@@ -247,6 +408,11 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
247
408
|
<Chip icon="⏰">{task.execution.cron} · 下次 {fmtTime(task.execution.nextRunAt)}</Chip>
|
|
248
409
|
)}
|
|
249
410
|
{task.blocked && <Chip icon="⛔" tone="urgent">受阻</Chip>}
|
|
411
|
+
{task.checklist !== undefined && task.checklist.length > 0 && (
|
|
412
|
+
<Chip icon="☑" tone={task.status === 'in_review' && task.checklist.some(i => !i.checked) ? 'urgent' : undefined}>
|
|
413
|
+
清单 {checklistProgress(task).done}/{task.checklist.length}
|
|
414
|
+
</Chip>
|
|
415
|
+
)}
|
|
250
416
|
{task.branch !== undefined && (
|
|
251
417
|
<Chip icon="🌿" tone={undefined}>Worktree · {task.branch.length > 28 ? `${task.branch.slice(0, 28)}…` : task.branch}</Chip>
|
|
252
418
|
)}
|
|
@@ -273,6 +439,18 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
273
439
|
>
|
|
274
440
|
⧉ 复制
|
|
275
441
|
</button>
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
className="dsh-atb-detail-edit"
|
|
445
|
+
title="把此任务的配置(含清单)保存为模板,新建任务时可用"
|
|
446
|
+
onClick={() => {
|
|
447
|
+
void controller.saveAsTemplate(task).then(ok => {
|
|
448
|
+
if (ok) showAlert('已存为模板(新建任务 ▼ 下拉可用,可在模板管理中改名)')
|
|
449
|
+
})
|
|
450
|
+
}}
|
|
451
|
+
>
|
|
452
|
+
⌗ 存为模板
|
|
453
|
+
</button>
|
|
276
454
|
{canRun && task.branch !== undefined && (
|
|
277
455
|
<button
|
|
278
456
|
type="button"
|
|
@@ -332,13 +510,19 @@ export function TaskDetail({ task, controller, now }: { task: TaskRecord; contro
|
|
|
332
510
|
|
|
333
511
|
<IsolationBlock task={task} controller={controller} />
|
|
334
512
|
|
|
513
|
+
<ReportBlock task={task} />
|
|
514
|
+
|
|
515
|
+
<ChecklistBlock task={task} controller={controller} />
|
|
516
|
+
|
|
335
517
|
<div className="dsh-atb-detail-actions">
|
|
336
518
|
<div className="dsh-atb-movebtns">
|
|
337
519
|
{moveTargets(task).map(to => to === 'done'
|
|
338
520
|
? (confirmDone
|
|
339
521
|
? (
|
|
340
522
|
<span key={to} className="dsh-atb-confirm">
|
|
341
|
-
<span className="dsh-atb-confirm-label"
|
|
523
|
+
<span className="dsh-atb-confirm-label" data-tone={unchecked > 0 ? 'bad' : undefined}>
|
|
524
|
+
{unchecked > 0 ? `仍有 ${unchecked} 项清单未勾选,确认完成?` : '确认完成?'}
|
|
525
|
+
</span>
|
|
342
526
|
<button type="button" className="dsh-atb-btn" data-primary="true" onClick={() => { void controller.move(task.id, task.version, 'done'); setConfirmDone(false) }}>确认</button>
|
|
343
527
|
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmDone(false)}>取消</button>
|
|
344
528
|
</span>
|