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.
Files changed (43) hide show
  1. package/README.md +62 -6
  2. package/lib/client.js +1839 -74
  3. package/lib/host/execution.js +199 -54
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +327 -0
  6. package/lib/host/git.js.map +1 -0
  7. package/lib/host/protocol-text.js +5 -3
  8. package/lib/host/protocol-text.js.map +1 -1
  9. package/lib/host/routes.js +435 -5
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/store.js +12 -0
  12. package/lib/host/store.js.map +1 -1
  13. package/lib/host/templates.js +166 -0
  14. package/lib/host/templates.js.map +1 -0
  15. package/lib/host/tools.js +217 -3
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +23 -3
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +286 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +74 -74
  23. package/src/client/api.ts +47 -3
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +118 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +360 -4
  28. package/src/client/board/TaskFormModal.tsx +193 -12
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/controller.ts +238 -11
  31. package/src/client/index.ts +18 -1
  32. package/src/client/styles.ts +198 -0
  33. package/src/host/execution.ts +301 -67
  34. package/src/host/git.ts +370 -0
  35. package/src/host/protocol-text.ts +5 -3
  36. package/src/host/routes.ts +483 -5
  37. package/src/host/store.ts +13 -0
  38. package/src/host/templates.ts +143 -0
  39. package/src/host/tools.ts +215 -2
  40. package/src/index.ts +30 -1
  41. package/src/shared/api.ts +89 -3
  42. package/src/shared/protocol.ts +408 -0
  43. 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 type { Urgency } from '../../shared/protocol.ts'
14
- import { nextCronTime, parseCron } from '../../shared/protocol.ts'
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; with `task` it
55
- * edits that record (project, urgency, execution, model included the GUI
56
- * is the owner surface).
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.model) : '')
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
+ }