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:
parent
a098cf721c
commit
996322f3a9
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue