gui: actions CRUD + FromTemplate bindings + UI

- CreateAction / DeleteAction Wails bindings

- FromTemplate / ListTemplates bindings with recursive tree creation

- Plugin manager stored in App struct for template access

- Action creation modal (title, kind, data) in Overview and Actions tabs

- Delete action button on action cards

- Template selector in new-node dialog
This commit is contained in:
mirivlad 2026-06-01 22:17:18 +08:00
parent a098cf721c
commit 996322f3a9
8 changed files with 233 additions and 34 deletions

View File

@ -17,6 +17,7 @@ import (
"verstak/internal/core/files" "verstak/internal/core/files"
"verstak/internal/core/notes" "verstak/internal/core/notes"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search" "verstak/internal/core/search"
"verstak/internal/core/storage" "verstak/internal/core/storage"
"verstak/internal/core/worklog" "verstak/internal/core/worklog"
@ -35,6 +36,7 @@ type App struct {
actions *actions.Service actions *actions.Service
worklog *worklog.Service worklog *worklog.Service
search *search.Service search *search.Service
plugins *plugins.Manager
vault string vault string
} }
@ -392,6 +394,66 @@ func (a *App) DeleteNode(id string) error {
return a.nodes.SoftDelete(id) return a.nodes.SoftDelete(id)
} }
// ============================================================
// Templates
// ============================================================
type TemplateDTO struct {
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
}
func (a *App) ListTemplates() []TemplateDTO {
templates := a.plugins.Templates()
out := make([]TemplateDTO, 0, len(templates))
for _, t := range templates {
out = append(out, TemplateDTO{
Name: t.Name,
Description: t.Description,
Icon: t.Icon,
})
}
return out
}
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
var tmpl *plugins.TemplateDefinition
for _, t := range a.plugins.Templates() {
if t.Name == template {
tmpl = &t
break
}
}
if tmpl == nil {
return nil, fmt.Errorf("template %q not found", template)
}
root, err := a.nodes.Create(parentID, tmpl.RootType, title, section)
if err != nil {
return nil, err
}
var createTree func(parentID string, nodes []plugins.TreeNode) error
createTree = func(parentID string, nodes []plugins.TreeNode) error {
for _, tn := range nodes {
child, err := a.nodes.Create(parentID, tn.Type, tn.Title, "")
if err != nil {
return err
}
if len(tn.Children) > 0 {
if err := createTree(child.ID, tn.Children); err != nil {
return err
}
}
}
return nil
}
if err := createTree(root.ID, tmpl.Tree); err != nil {
return nil, err
}
dto := toNodeDTO(root)
return &dto, nil
}
// ============================================================ // ============================================================
// Notes // Notes
// ============================================================ // ============================================================
@ -658,6 +720,24 @@ func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
return result, nil return result, nil
} }
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
if err != nil {
return nil, err
}
return &ActionDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Title: rec.Title,
Type: rec.Kind,
Data: data,
}, nil
}
func (a *App) DeleteAction(id string) error {
return a.actions.Delete(id)
}
func (a *App) RunAction(id string) error { func (a *App) RunAction(id string) error {
_, err := a.actions.Run(id) _, err := a.actions.Run(id)
return err return err

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-DtITCkHU.js"></script> <script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-5x3eoU2l.css"> <link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -50,7 +50,8 @@ func main() {
activitySvc := activity.NewService(db) activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db) worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db) searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover() pm := plugins.NewManager(abs)
pm.Discover()
app := &App{ app := &App{
db: db, db: db,
@ -61,6 +62,7 @@ func main() {
actions: actionSvc, actions: actionSvc,
worklog: worklogSvc, worklog: worklogSvc,
search: searchSvc, search: searchSvc,
plugins: pm,
vault: abs, vault: abs,
} }

View File

@ -47,8 +47,23 @@
let showCreateNode = false let showCreateNode = false
let newNodeTitle = '' let newNodeTitle = ''
let newNodeSection = 'clients' let newNodeSection = 'clients'
let newNodeTemplate = ''
let templates = []
let showCreateNote = false let showCreateNote = false
let newNoteTitle = '' let newNoteTitle = ''
let showCreateAction = false
let newActionTitle = ''
let newActionKind = 'open_url'
let newActionData = ''
let actionKinds = [
{ id: 'open_url', label: 'Открыть URL' },
{ id: 'open_file', label: 'Открыть файл' },
{ id: 'open_folder', label: 'Открыть папку' },
{ id: 'run_command', label: 'Запустить команду' },
{ id: 'run_script', label: 'Запустить скрипт' },
{ id: 'open_terminal', label: 'Открыть терминал' },
{ id: 'launch_app', label: 'Запустить приложение' },
]
let loading = true let loading = true
let importing = false let importing = false
let importSummary = null let importSummary = null
@ -586,14 +601,22 @@
showCreateNode = true showCreateNode = true
newNodeTitle = '' newNodeTitle = ''
newNodeSection = selectedSection || 'clients' newNodeSection = selectedSection || 'clients'
newNodeTemplate = ''
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
} }
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' } function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
async function submitCreateNode() { async function submitCreateNode() {
if (!newNodeTitle.trim()) return if (!newNodeTitle.trim()) return
try { try {
const node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection) let node
if (newNodeTemplate) {
node = await wailsCall('FromTemplate', '', 'case', newNodeTitle.trim(), newNodeSection, newNodeTemplate)
} else {
node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
}
showCreateNode = false showCreateNode = false
newNodeTitle = '' newNodeTitle = ''
newNodeTemplate = ''
await selectSection(newNodeSection) await selectSection(newNodeSection)
} catch (e) { error = String(e) } } catch (e) { error = String(e) }
} }
@ -820,6 +843,37 @@
if (n >= 2 && n <= 4) return few if (n >= 2 && n <= 4) return few
return many return many
} }
// ===== Actions =====
function openCreateAction() {
showCreateAction = true
newActionTitle = ''
newActionKind = 'open_url'
newActionData = ''
}
function cancelCreateAction() { showCreateAction = false; newActionTitle = ''; newActionData = '' }
async function submitCreateAction() {
if (!newActionTitle.trim() || !newActionData.trim() || !selectedNode) return
try {
const action = await wailsCall('CreateAction', selectedNode.id, newActionKind, newActionTitle.trim(), newActionData.trim())
if (action && action.id) {
actions = [...actions, action]
}
showCreateAction = false
newActionTitle = ''
newActionData = ''
} catch (e) { error = String(e) }
}
async function deleteAction(id) {
try {
await wailsCall('DeleteAction', id)
actions = actions.filter(a => a.id !== id)
} catch (e) { error = String(e) }
}
function actionKindLabel(kind) {
const k = actionKinds.find(k => k.id === kind)
return k ? k.label : kind
}
async function openNodeById(id) { async function openNodeById(id) {
try { try {
const node = await wailsCall('GetNodeDetail', id) const node = await wailsCall('GetNodeDetail', id)
@ -929,7 +983,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
Добавить файл Добавить файл
</button> </button>
<button class="qa-btn" disabled title="Следующий этап"> <button class="qa-btn" on:click={openCreateAction}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Добавить действие Добавить действие
</button> </button>
@ -1081,16 +1135,30 @@
{/if} {/if}
{:else if activeTab === 'actions'} {:else if activeTab === 'actions'}
<div class="actions-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
</div>
{#if actions.length === 0} {#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div> <div class="empty-state"><p>Действий пока нет</p></div>
{:else} {:else}
{#each actions as action} {#each actions as action}
<div class="action-card"> <div class="action-card">
<span>{action.title}</span><span class="action-type">{action.type}</span> <div class="action-info">
<span class="action-title">{action.title}</span>
<span class="action-type">{actionKindLabel(action.type)}</span>
<span class="action-data">{action.data}</span>
</div>
<div class="action-btns">
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button> <button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div> </div>
{/each} {/each}
{/if} {/if}
</div>
{:else if activeTab === 'worklog'} {:else if activeTab === 'worklog'}
<div class="worklog-tab"> <div class="worklog-tab">
@ -1253,6 +1321,17 @@
{/each} {/each}
</select> </select>
</div> </div>
{#if templates.length > 0}
<div class="form-group">
<label>Шаблон (опционально)</label>
<select bind:value={newNodeTemplate}>
<option value="">Без шаблона</option>
{#each templates as t}
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
{/each}
</select>
</div>
{/if}
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button> <button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
<button class="btn" on:click={cancelCreateNode}>Отмена</button> <button class="btn" on:click={cancelCreateNode}>Отмена</button>
@ -1261,6 +1340,37 @@
</div> </div>
{/if} {/if}
{#if showCreateAction}
<div class="modal-overlay" on:click|self={cancelCreateAction}>
<div class="modal">
<h3>Новое действие</h3>
<div class="form-group">
<label>Название</label>
<input type="text" placeholder="Например: Открыть сайт" bind:value={newActionTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
</div>
<div class="form-group">
<label>Тип</label>
<select bind:value={newActionKind}>
{#each actionKinds as k}
<option value={k.id}>{k.label}</option>
{/each}
</select>
</div>
<div class="form-group">
<label>{newActionKind === 'open_url' ? 'URL' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? 'Путь' : 'Команда'}</label>
<input type="text" placeholder={newActionKind === 'open_url' ? 'https://example.com' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? '/path/to/file' : 'команда'}
bind:value={newActionData}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateAction}>Создать</button>
<button class="btn" on:click={cancelCreateAction}>Отмена</button>
</div>
</div>
</div>
{/if}
{#if showImportDialog && importSummary} {#if showImportDialog && importSummary}
<div class="modal-overlay" on:click|self={cancelImport}> <div class="modal-overlay" on:click|self={cancelImport}>
<div class="modal"> <div class="modal">
@ -1405,8 +1515,15 @@
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; } .wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
/* Actions */ /* Actions */
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } .actions-tab { padding: 24px; }
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; } .action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.action-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.action-title { font-weight: 500; }
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.action-data { font-size: 11px; color: #555; font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.action-btns { display: flex; gap: 4px; flex-shrink: 0; }
.action-btns .btn-danger { color: #ff6b6b; border-color: #4a2222; padding: 4px 8px; }
.action-btns .btn-danger:hover { background: #3a2222; }
/* Empty states */ /* Empty states */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }