dsh-taskboard 0.2.2 → 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 +62 -6
- package/lib/client.js +1839 -74
- package/lib/host/execution.js +199 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +327 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +435 -5
- 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 +217 -3
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +23 -3
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +286 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +47 -3
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +118 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +360 -4
- package/src/client/board/TaskFormModal.tsx +193 -12
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +238 -11
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +198 -0
- package/src/host/execution.ts +301 -67
- package/src/host/git.ts +370 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +483 -5
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +215 -2
- package/src/index.ts +30 -1
- package/src/shared/api.ts +89 -3
- package/src/shared/protocol.ts +408 -0
- package/src/shared/version.ts +1 -1
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
12
12
|
import type { BoardController } from '../controller.ts'
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
13
|
+
import { loadDefaultIsolation, saveDefaultIsolation } from '../controller.ts'
|
|
14
|
+
import type { TaskTemplateSpec } from '../../shared/api.ts'
|
|
15
|
+
import type { ChecklistItem, IsolationMode, Urgency } from '../../shared/protocol.ts'
|
|
16
|
+
import { MAX_CHECKLIST_ITEMS, nextCronTime, parseCron } from '../../shared/protocol.ts'
|
|
15
17
|
import { fmtTime } from './TaskBoard.tsx'
|
|
16
18
|
|
|
17
19
|
/** One row of the configured model catalog (from llm.models). */
|
|
@@ -50,25 +52,98 @@ function Field({ label, required = false, full = false, children }: {
|
|
|
50
52
|
)
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/** One editable checklist row (create: fresh unchecked; edit: preserved ids/flags). */
|
|
56
|
+
interface CheckRow {
|
|
57
|
+
id?: string
|
|
58
|
+
text: string
|
|
59
|
+
checked: boolean
|
|
60
|
+
checkedBy?: string
|
|
61
|
+
checkedAt?: number
|
|
62
|
+
note?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The checklist (DoD) editor: toggle + text + remove per row, add button,
|
|
67
|
+
* cap-enforced. Edit mode preserves checked state and notes (the GUI
|
|
68
|
+
* replaces the whole list on save).
|
|
69
|
+
*/
|
|
70
|
+
function ChecklistEditor({ rows, onChange, editing }: { rows: CheckRow[]; onChange: (rows: CheckRow[]) => void; editing: boolean }) {
|
|
71
|
+
const setRow = (index: number, patch: Partial<CheckRow>): void => {
|
|
72
|
+
const next = rows.map((row, i) => i === index ? { ...row, ...patch } : row)
|
|
73
|
+
onChange(next)
|
|
74
|
+
}
|
|
75
|
+
const checked = rows.filter(r => r.checked).length
|
|
76
|
+
return (
|
|
77
|
+
<div className="dsh-atb-cke">
|
|
78
|
+
{rows.map((row, index) => (
|
|
79
|
+
<div key={row.id ?? `new-${index}`} className="dsh-atb-cke-row">
|
|
80
|
+
{editing && (
|
|
81
|
+
<input
|
|
82
|
+
type="checkbox"
|
|
83
|
+
className="dsh-atb-cke-box"
|
|
84
|
+
checked={row.checked}
|
|
85
|
+
title={`勾选状态随保存保留(当前勾选人:${row.checkedBy ?? '未勾选'})`}
|
|
86
|
+
onChange={e => setRow(index, { checked: e.target.checked })}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
<input
|
|
90
|
+
className="dsh-atb-cke-text"
|
|
91
|
+
value={row.text}
|
|
92
|
+
maxLength={200}
|
|
93
|
+
placeholder={`验收项 ${index + 1}(完成标准)`}
|
|
94
|
+
spellCheck={false}
|
|
95
|
+
onChange={e => setRow(index, { text: e.target.value })}
|
|
96
|
+
/>
|
|
97
|
+
<button type="button" className="dsh-atb-cke-del" title="删除该验收项" onClick={() => onChange(rows.filter((_, i) => i !== index))}>✕</button>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
{rows.length < MAX_CHECKLIST_ITEMS && (
|
|
101
|
+
<button type="button" className="dsh-atb-cke-add" onClick={() => onChange([...rows, { text: '', checked: false }])}>+ 添加验收项</button>
|
|
102
|
+
)}
|
|
103
|
+
{rows.length > 0 && (
|
|
104
|
+
<span className="dsh-atb-cke-hint">{editing ? `已勾选 ${checked}/${rows.length}(保存将整体覆盖清单,勾选状态保留)` : `共 ${rows.length} 项,执行会话按清单干活并逐项勾选,未完成项验收时高亮`}</span>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
53
110
|
/**
|
|
54
|
-
* The form modal. Without `task` it composes a new task
|
|
55
|
-
*
|
|
56
|
-
* is the owner
|
|
111
|
+
* The form modal. Without `task` it composes a new task (optionally
|
|
112
|
+
* prefilled from a chosen template); with `task` it edits that record
|
|
113
|
+
* (project, urgency, execution, model included — the GUI is the owner
|
|
114
|
+
* surface).
|
|
57
115
|
* @param controller - the controller.
|
|
58
116
|
* @param task - the task being edited (create mode when absent).
|
|
59
117
|
*/
|
|
60
118
|
export function TaskFormModal({ controller, task }: { controller: BoardController; task?: TaskRecordLike }) {
|
|
61
119
|
const state = controller.getSnapshot()
|
|
120
|
+
const prefill: TaskTemplateSpec | undefined = state.templatePrefill
|
|
62
121
|
const editing = task !== undefined
|
|
63
|
-
const [title, setTitle] = useState(task?.title ?? '')
|
|
64
|
-
const [description, setDescription] = useState(task?.description ?? '')
|
|
65
|
-
const [prompt, setPrompt] = useState(task?.prompt ?? '')
|
|
122
|
+
const [title, setTitle] = useState(task?.title ?? prefill?.title ?? '')
|
|
123
|
+
const [description, setDescription] = useState(task?.description ?? prefill?.description ?? '')
|
|
124
|
+
const [prompt, setPrompt] = useState(task?.prompt ?? prefill?.prompt ?? '')
|
|
66
125
|
const [workspaceId, setWorkspaceId] = useState(task?.workspaceId ?? state.filters.workspaceId ?? state.workspaces[0]?.id ?? '')
|
|
67
|
-
const [urgency, setUrgency] = useState<Urgency>(task?.urgency ?? 'normal')
|
|
68
|
-
const [mode, setMode] = useState<'claim' | 'scheduled'>(task?.execution.mode === 'scheduled' ? 'scheduled' : 'claim')
|
|
69
|
-
const [cron, setCron] = useState(task?.execution.cron ?? '0 9 * * *')
|
|
126
|
+
const [urgency, setUrgency] = useState<Urgency>(task?.urgency ?? (prefill?.urgency === 'urgent' || prefill?.urgency === 'relaxed' ? prefill.urgency : 'normal'))
|
|
127
|
+
const [mode, setMode] = useState<'claim' | 'scheduled'>(task?.execution.mode === 'scheduled' || prefill?.execution?.mode === 'scheduled' ? 'scheduled' : 'claim')
|
|
128
|
+
const [cron, setCron] = useState(task?.execution.cron ?? prefill?.execution?.cron ?? '0 9 * * *')
|
|
70
129
|
const [catalog, setCatalog] = useState<CatalogModel[]>([])
|
|
71
|
-
const [model, setModel] = useState(task?.model !== undefined ? JSON.stringify(task
|
|
130
|
+
const [model, setModel] = useState(task?.model !== undefined || prefill?.model !== undefined ? JSON.stringify(task?.model ?? prefill?.model) : '')
|
|
131
|
+
// Preset roster (0.3.3): create mode PRE-SELECTS the deployment default
|
|
132
|
+
// (标准模式 in this deployment); '' = 跟随部署默认 (submit omits the field).
|
|
133
|
+
const initialPreset = task?.presetId ?? prefill?.presetId ?? ''
|
|
134
|
+
const [presetId, setPresetId] = useState(initialPreset)
|
|
135
|
+
const [presets, setPresets] = useState<Array<{ id: string; name?: string }>>([])
|
|
136
|
+
const [presetDefault, setPresetDefault] = useState<string | undefined>(undefined)
|
|
137
|
+
// Isolation toggle: create mode starts from the remembered choice (default
|
|
138
|
+
// on) or the template's choice; edit mode starts from the task and locks
|
|
139
|
+
// once execution began.
|
|
140
|
+
const [isolation, setIsolation] = useState<IsolationMode>(task?.isolation ?? (prefill?.isolation === 'none' ? 'none' : prefill?.isolation === 'worktree' ? 'worktree' : loadDefaultIsolation()))
|
|
141
|
+
// Checklist (0.4.0): create = template texts / blank rows; edit = live items.
|
|
142
|
+
const [checkRows, setCheckRows] = useState<CheckRow[]>(
|
|
143
|
+
task?.checklist !== undefined && task.checklist.length > 0
|
|
144
|
+
? task.checklist.map(i => ({ ...i }))
|
|
145
|
+
: (prefill?.checklist ?? []).map(text => ({ text, checked: false })),
|
|
146
|
+
)
|
|
72
147
|
const titleRef = useRef<HTMLInputElement>(null)
|
|
73
148
|
|
|
74
149
|
// Focus the title and close on Esc while the dialog is open.
|
|
@@ -88,6 +163,19 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
88
163
|
void face().then(setCatalog).catch(() => setCatalog([]))
|
|
89
164
|
}, [controller])
|
|
90
165
|
|
|
166
|
+
// Preset roster: same lazy face; pre-select the deployment default in
|
|
167
|
+
// create mode (unless a template pinned one) so executions run with a
|
|
168
|
+
// real tool set out of the box.
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
const face = (controller as unknown as { presetCatalog?: () => Promise<{ presets: Array<{ id: string; name?: string }>; defaultId?: string }> }).presetCatalog
|
|
171
|
+
if (face === undefined) return
|
|
172
|
+
void face().then(roster => {
|
|
173
|
+
setPresets(roster.presets)
|
|
174
|
+
setPresetDefault(roster.defaultId)
|
|
175
|
+
if (task?.presetId === undefined && initialPreset === '' && roster.defaultId !== undefined) setPresetId(roster.defaultId)
|
|
176
|
+
}).catch(() => setPresets([]))
|
|
177
|
+
}, [controller, task?.presetId, initialPreset])
|
|
178
|
+
|
|
91
179
|
// Live cron validation + next-run preview (same math as the host).
|
|
92
180
|
const cronMatch = mode === 'scheduled' ? parseCron(cron.trim()) : null
|
|
93
181
|
const nextRun = cronMatch !== null ? nextCronTime(cronMatch, Date.now()) : null
|
|
@@ -97,9 +185,33 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
97
185
|
// A task already in progress cannot be run again (host rejects it).
|
|
98
186
|
const runBlocked = editing && task.status === 'in_progress'
|
|
99
187
|
|
|
188
|
+
// Isolation editability: locked once the task has execution history (the
|
|
189
|
+
// branch and its baseline depend on the choice — plan §3.1).
|
|
190
|
+
const isolationLocked = editing && ((task.executions?.length ?? 0) > 0 || task.status === 'in_progress')
|
|
191
|
+
const gitOk = controller.gitAvailable(workspaceId)
|
|
192
|
+
// Non-git project: the worktree option is disabled; submitting keeps the
|
|
193
|
+
// default (runtime auto-degrades with a note) instead of persisting 'none'.
|
|
194
|
+
const isolationDisabled = isolationLocked || !gitOk
|
|
195
|
+
|
|
196
|
+
/** Isolation payload for submit: undefined keeps the default (degrades naturally). */
|
|
197
|
+
const isolationPayload = (): string | undefined => {
|
|
198
|
+
if (!gitOk) return undefined
|
|
199
|
+
if (!editing) saveDefaultIsolation(isolation)
|
|
200
|
+
return isolation
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Preset payload: '' = follow the deployment default (submit omits). */
|
|
204
|
+
const presetPayload = (): string | undefined => (presetId.trim().length > 0 ? presetId.trim() : undefined)
|
|
205
|
+
|
|
206
|
+
/** Checklist rows with non-empty text (blank rows are dropped on submit). */
|
|
207
|
+
const filledRows = (): CheckRow[] => checkRows.map(r => ({ ...r, text: r.text.trim() })).filter(r => r.text.length > 0)
|
|
208
|
+
|
|
100
209
|
const submit = (): void => {
|
|
101
210
|
if (!valid) return
|
|
102
211
|
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
212
|
+
const isolationOut = isolationPayload()
|
|
213
|
+
const presetOut = presetPayload()
|
|
214
|
+
const rows = filledRows()
|
|
103
215
|
if (editing) {
|
|
104
216
|
void controller.update(task.id, task.version, {
|
|
105
217
|
title,
|
|
@@ -110,6 +222,10 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
110
222
|
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
111
223
|
// '' in edit mode clears the pinned model back to the default.
|
|
112
224
|
model: picked ?? null,
|
|
225
|
+
...(isolationOut !== undefined && !isolationLocked ? { isolation: isolationOut } : {}),
|
|
226
|
+
presetId: presetOut ?? null,
|
|
227
|
+
// [] clears the checklist (host deletes the field on empty).
|
|
228
|
+
checklist: rows.length > 0 ? rows : null,
|
|
113
229
|
})
|
|
114
230
|
} else {
|
|
115
231
|
void controller.create({
|
|
@@ -120,6 +236,9 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
120
236
|
prompt: prompt.length > 0 ? prompt : undefined,
|
|
121
237
|
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
122
238
|
model: picked,
|
|
239
|
+
...(isolationOut !== undefined ? { isolation: isolationOut } : {}),
|
|
240
|
+
...(presetOut !== undefined ? { presetId: presetOut } : {}),
|
|
241
|
+
...(rows.length > 0 ? { checklist: rows.map(r => r.text) } : {}),
|
|
123
242
|
})
|
|
124
243
|
}
|
|
125
244
|
}
|
|
@@ -128,6 +247,9 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
128
247
|
const submitAndRun = (): void => {
|
|
129
248
|
if (!valid || runBlocked) return
|
|
130
249
|
const picked = model !== '' ? (JSON.parse(model) as { provider: string; model: string }) : undefined
|
|
250
|
+
const isolationOut = isolationPayload()
|
|
251
|
+
const presetOut = presetPayload()
|
|
252
|
+
const rows = filledRows()
|
|
131
253
|
if (editing) {
|
|
132
254
|
void (async () => {
|
|
133
255
|
const saved = await controller.update(task.id, task.version, {
|
|
@@ -138,6 +260,9 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
138
260
|
workspaceId,
|
|
139
261
|
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
140
262
|
model: picked ?? null,
|
|
263
|
+
...(isolationOut !== undefined && !isolationLocked ? { isolation: isolationOut } : {}),
|
|
264
|
+
presetId: presetOut ?? null,
|
|
265
|
+
checklist: rows.length > 0 ? rows : null,
|
|
141
266
|
})
|
|
142
267
|
if (saved) await controller.run(task.id)
|
|
143
268
|
})()
|
|
@@ -151,6 +276,9 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
151
276
|
prompt: prompt.length > 0 ? prompt : undefined,
|
|
152
277
|
execution: mode === 'scheduled' ? { mode, cron: cron.trim() } : { mode },
|
|
153
278
|
model: picked,
|
|
279
|
+
...(isolationOut !== undefined ? { isolation: isolationOut } : {}),
|
|
280
|
+
...(presetOut !== undefined ? { presetId: presetOut } : {}),
|
|
281
|
+
...(rows.length > 0 ? { checklist: rows.map(r => r.text) } : {}),
|
|
154
282
|
})
|
|
155
283
|
if (id !== undefined) await controller.run(id)
|
|
156
284
|
})()
|
|
@@ -199,6 +327,19 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
199
327
|
</select>
|
|
200
328
|
</Field>
|
|
201
329
|
|
|
330
|
+
{presets.length > 0 && (
|
|
331
|
+
<Field label="执行模式(preset)">
|
|
332
|
+
<select value={presetId} onChange={e => setPresetId(e.target.value)} title="执行会话按该 preset 组合(决定工具集与人设);默认 = 部署默认 preset">
|
|
333
|
+
<option value="">跟随部署默认{presetDefault !== undefined ? `(当前:${presets.find(p => p.id === presetDefault)?.name ?? presetDefault})` : ''}</option>
|
|
334
|
+
{presets.map(p => (
|
|
335
|
+
<option key={p.id} value={p.id}>
|
|
336
|
+
{p.name ?? p.id}{p.id === presetDefault ? '(部署默认)' : ''}
|
|
337
|
+
</option>
|
|
338
|
+
))}
|
|
339
|
+
</select>
|
|
340
|
+
</Field>
|
|
341
|
+
)}
|
|
342
|
+
|
|
202
343
|
<Field label="紧急度" full>
|
|
203
344
|
<div className="dsh-atb-urgency-picker">
|
|
204
345
|
{URGENCY_OPTIONS.map(o => (
|
|
@@ -263,6 +404,42 @@ export function TaskFormModal({ controller, task }: { controller: BoardControlle
|
|
|
263
404
|
</span>
|
|
264
405
|
</Field>
|
|
265
406
|
)}
|
|
407
|
+
|
|
408
|
+
<Field label="执行隔离" full>
|
|
409
|
+
<div className="dsh-atb-mode-picker" data-disabled={isolationDisabled ? 'true' : undefined}>
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
className="dsh-atb-mode-opt"
|
|
413
|
+
data-on={isolation === 'worktree'}
|
|
414
|
+
disabled={isolationDisabled}
|
|
415
|
+
title={isolationLocked ? '任务已有执行记录,隔离方式已锁定' : !gitOk ? '当前项目非 git 仓库' : '每次执行在独立 worktree 分支上进行'}
|
|
416
|
+
onClick={() => setIsolation('worktree')}
|
|
417
|
+
>
|
|
418
|
+
<span className="dsh-atb-mode-name">🌿 Worktree 隔离</span>
|
|
419
|
+
<span className="dsh-atb-mode-hint">
|
|
420
|
+
{isolationLocked ? '已锁定(执行开始后不可更改)' : !gitOk ? '当前项目非 git 仓库' : '独立分支 task/标题+ID,互不污染'}
|
|
421
|
+
</span>
|
|
422
|
+
</button>
|
|
423
|
+
<button
|
|
424
|
+
type="button"
|
|
425
|
+
className="dsh-atb-mode-opt"
|
|
426
|
+
data-on={isolation === 'none'}
|
|
427
|
+
disabled={isolationDisabled}
|
|
428
|
+
title={isolationLocked ? '任务已有执行记录,隔离方式已锁定' : '直接在项目目录执行(不使用 git)'}
|
|
429
|
+
onClick={() => setIsolation('none')}
|
|
430
|
+
>
|
|
431
|
+
<span className="dsh-atb-mode-name">📁 原目录执行</span>
|
|
432
|
+
<span className="dsh-atb-mode-hint">{isolationLocked ? '已锁定(执行开始后不可更改)' : !gitOk ? '当前项目非 git 仓库,将在原目录执行' : '不使用 git,直接在项目目录工作'}</span>
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
{!gitOk && !isolationLocked && (
|
|
436
|
+
<span className="dsh-atb-isolation-note">当前项目非 git 仓库,将在原目录执行(任务仍按默认配置创建,运行时自动降级)</span>
|
|
437
|
+
)}
|
|
438
|
+
</Field>
|
|
439
|
+
|
|
440
|
+
<Field label={editing ? '验收清单(DoD)' : '验收清单(DoD,可选)'} full>
|
|
441
|
+
<ChecklistEditor rows={checkRows} onChange={setCheckRows} editing={editing} />
|
|
442
|
+
</Field>
|
|
266
443
|
</div>
|
|
267
444
|
|
|
268
445
|
<div className="dsh-atb-modal-foot">
|
|
@@ -300,4 +477,8 @@ interface TaskRecordLike {
|
|
|
300
477
|
urgency: Urgency
|
|
301
478
|
execution: { mode: 'claim' | 'scheduled'; cron?: string }
|
|
302
479
|
model?: { provider: string; model: string }
|
|
480
|
+
isolation?: IsolationMode
|
|
481
|
+
presetId?: string
|
|
482
|
+
checklist?: ChecklistItem[]
|
|
483
|
+
executions?: unknown[]
|
|
303
484
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template-manager modal (0.4.0): rename / delete / use the stored task
|
|
3
|
+
* templates. Templates live host-side (side file next to the ledger) and
|
|
4
|
+
* prefill the create form from the + 新建任务 ▼ dropdown.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-taskboard/client/board/TemplateManager
|
|
7
|
+
*/
|
|
8
|
+
import { useState } from 'react'
|
|
9
|
+
import type { BoardController } from '../controller.ts'
|
|
10
|
+
import { useAlert } from './AlertModal.tsx'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The template manager modal.
|
|
14
|
+
* @param controller - the controller.
|
|
15
|
+
*/
|
|
16
|
+
export function TemplateManager({ controller }: { controller: BoardController }) {
|
|
17
|
+
const state = controller.getSnapshot()
|
|
18
|
+
const [edits, setEdits] = useState<Record<string, string>>({})
|
|
19
|
+
const [confirmId, setConfirmId] = useState<string | undefined>(undefined)
|
|
20
|
+
const { alert: showAlert, el: alertEl } = useAlert()
|
|
21
|
+
|
|
22
|
+
const close = (): void => controller.closeTemplateManager()
|
|
23
|
+
|
|
24
|
+
const nameOf = (id: string, fallback: string): string => edits[id] ?? fallback
|
|
25
|
+
|
|
26
|
+
/** Save one template's rename. */
|
|
27
|
+
const save = (id: string, name: string): void => {
|
|
28
|
+
const template = state.templates.find(t => t.id === id)
|
|
29
|
+
if (template === undefined || name === template.name) return
|
|
30
|
+
void controller.upsertTemplate({ id, name, task: template.task }).then(ok => {
|
|
31
|
+
if (ok) {
|
|
32
|
+
setEdits(prev => { const next = { ...prev }; delete next[id]; return next })
|
|
33
|
+
showAlert('模板已改名')
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="dsh-atb-modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close() }}>
|
|
40
|
+
<div className="dsh-atb-modal dsh-atb-tplm" role="dialog" aria-modal="true" aria-label="管理模板">
|
|
41
|
+
<div className="dsh-atb-modal-head">
|
|
42
|
+
<span className="dsh-atb-modal-headicon">⌗</span>
|
|
43
|
+
<div className="dsh-atb-modal-headtext">
|
|
44
|
+
<h3>任务模板</h3>
|
|
45
|
+
<p>新建任务 ▼ 下拉的模板:改名 / 删除 / 直接使用;任务详情页「存为模板」可新增</p>
|
|
46
|
+
</div>
|
|
47
|
+
<button type="button" className="dsh-atb-modal-close" aria-label="关闭" onClick={close}>✕</button>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="dsh-atb-modal-body">
|
|
50
|
+
{state.templates.length === 0
|
|
51
|
+
? <div className="dsh-atb-empty2">暂无模板 — 在任务详情页点「存为模板」把常用配置沉淀下来</div>
|
|
52
|
+
: (
|
|
53
|
+
<div className="dsh-atb-tplm-list">
|
|
54
|
+
{state.templates.map(t => (
|
|
55
|
+
<div key={t.id} className="dsh-atb-tplm-row">
|
|
56
|
+
<input
|
|
57
|
+
className="dsh-atb-tplm-name"
|
|
58
|
+
value={nameOf(t.id, t.name)}
|
|
59
|
+
maxLength={60}
|
|
60
|
+
spellCheck={false}
|
|
61
|
+
aria-label={`模板名 ${t.name}`}
|
|
62
|
+
onChange={e => setEdits(prev => ({ ...prev, [t.id]: e.target.value }))}
|
|
63
|
+
onKeyDown={e => {
|
|
64
|
+
if (e.key === 'Enter') save(t.id, nameOf(t.id, t.name))
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
<span className="dsh-atb-tplm-meta">
|
|
68
|
+
{t.builtin === true ? '内置' : '自建'}
|
|
69
|
+
{t.task.checklist !== undefined && t.task.checklist.length > 0 ? ` · 清单 ${t.task.checklist.length} 项` : ''}
|
|
70
|
+
{t.task.urgency !== undefined ? ` · ${t.task.urgency}` : ''}
|
|
71
|
+
</span>
|
|
72
|
+
<span className="dsh-atb-tplm-btns">
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className="dsh-atb-btn"
|
|
76
|
+
disabled={nameOf(t.id, t.name) === t.name || nameOf(t.id, t.name).trim().length === 0}
|
|
77
|
+
title="保存改名"
|
|
78
|
+
onClick={() => save(t.id, nameOf(t.id, t.name))}
|
|
79
|
+
>
|
|
80
|
+
改名
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="dsh-atb-btn"
|
|
85
|
+
title="用此模板打开新建表单"
|
|
86
|
+
onClick={() => {
|
|
87
|
+
close()
|
|
88
|
+
controller.newFromTemplate(t.task)
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
用此新建
|
|
92
|
+
</button>
|
|
93
|
+
{confirmId === t.id
|
|
94
|
+
? (
|
|
95
|
+
<>
|
|
96
|
+
<button type="button" className="dsh-atb-btn" data-danger="true" onClick={() => { void controller.deleteTemplate(t.id); setConfirmId(undefined) }}>确认删除</button>
|
|
97
|
+
<button type="button" className="dsh-atb-btn" onClick={() => setConfirmId(undefined)}>取消</button>
|
|
98
|
+
</>
|
|
99
|
+
)
|
|
100
|
+
: (
|
|
101
|
+
<button type="button" className="dsh-atb-btn" data-danger="true" title="删除该模板" onClick={() => setConfirmId(t.id)}>
|
|
102
|
+
🗑
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
<div className="dsh-atb-modal-foot">
|
|
112
|
+
<span className="dsh-atb-modal-hint">模板随台账一同保存在 DSH 主目录,升级不丢</span>
|
|
113
|
+
<span className="dsh-atb-modal-footbtns">
|
|
114
|
+
<button type="button" className="dsh-atb-btn" onClick={close}>关闭</button>
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
{alertEl}
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
}
|