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/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/worklog"
@ -35,6 +36,7 @@ type App struct {
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
vault string
}
@ -392,6 +394,66 @@ func (a *App) DeleteNode(id string) error {
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
// ============================================================
@ -658,6 +720,24 @@ func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
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 {
_, err := a.actions.Run(id)
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;
}
</style>
<script type="module" crossorigin src="/assets/main-DtITCkHU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-5x3eoU2l.css">
<script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
</head>
<body>
<div id="app"></div>

View File

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

View File

@ -47,8 +47,23 @@
let showCreateNode = false
let newNodeTitle = ''
let newNodeSection = 'clients'
let newNodeTemplate = ''
let templates = []
let showCreateNote = false
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 importing = false
let importSummary = null
@ -586,14 +601,22 @@
showCreateNode = true
newNodeTitle = ''
newNodeSection = selectedSection || 'clients'
newNodeTemplate = ''
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
}
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
async function submitCreateNode() {
if (!newNodeTitle.trim()) return
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
newNodeTitle = ''
newNodeTemplate = ''
await selectSection(newNodeSection)
} catch (e) { error = String(e) }
}
@ -820,6 +843,37 @@
if (n >= 2 && n <= 4) return few
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) {
try {
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>
Добавить файл
</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>
Добавить действие
</button>
@ -1081,16 +1135,30 @@
{/if}
{:else if activeTab === 'actions'}
{#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div>
{:else}
{#each actions as action}
<div class="action-card">
<span>{action.title}</span><span class="action-type">{action.type}</span>
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
</div>
{/each}
{/if}
<div class="actions-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
</div>
{#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div>
{:else}
{#each actions as action}
<div class="action-card">
<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 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>
{/each}
{/if}
</div>
{:else if activeTab === 'worklog'}
<div class="worklog-tab">
@ -1253,6 +1321,17 @@
{/each}
</select>
</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">
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
@ -1261,6 +1340,37 @@
</div>
{/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}
<div class="modal-overlay" on:click|self={cancelImport}>
<div class="modal">
@ -1405,8 +1515,15 @@
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
/* Actions */
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
.actions-tab { padding: 24px; }
.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-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }