step 7: actions — table, service, CLI, GUI tab + confirm dialog
- Migration 005: actions table (node_id, title, kind, command, args_json, working_dir, url, confirm_required, capture_output) - ActionService: Create, Get, ListByNode, Delete, Run Run dispatches: open_url/file/folder (xdg-open), run_command/script (exec.Command), open_terminal, launch_app - CLI: verstak action add/list/run/delete 'run' shows confirm prompt for confirm_required actions - GUI 'Действия' tab: button list with kind label, confirm_required opens editor overlay with action info + confirm, delete button - 7 unit tests for ActionService Acceptance: go build ./... pass, go test ./... pass.
This commit is contained in:
@@ -526,7 +526,7 @@ function switchTabNode(t){
|
||||
else api('/api/nodes/'+id).then(d=>{nodeCache[id]={detail:d,ts:Date.now()};renderNodeDash(d)});
|
||||
}else if(t==='notes') loadNodeNotes(id);
|
||||
else if(t==='files') loadNodeFiles(id);
|
||||
else if(t==='actions') setCnt('<div class="empty" style="margin-top:60px">Действия — в разработке</div>');
|
||||
else if(t==='actions') loadNodeActions(id);
|
||||
else if(t==='worklog') setCnt('<div class="empty" style="margin-top:60px">Журнал — в разработке</div>');
|
||||
else if(t==='activity') setCnt('<div class="empty" style="margin-top:60px">Активность — в разработке</div>');
|
||||
}
|
||||
@@ -563,6 +563,48 @@ async function loadNodeFiles(nodeId){
|
||||
|
||||
function E(msg){setCnt('<div class="empty" style="margin-top:60px">'+esc(msg)+'</div>')}
|
||||
|
||||
async function loadNodeActions(nodeId){
|
||||
try{
|
||||
const list=await api('/api/actions?node='+nodeId);
|
||||
if(!list.length){setCnt('<div class="empty" style="margin-top:60px">Нет действий. <button class="btn primary" style="margin-top:8px" onclick="openM(\'m-action\')">+ Добавить</button></div>');return}
|
||||
let h='<div style="display:flex;flex-direction:column;gap:10px">';
|
||||
for(const a of list){
|
||||
h+='<div style="display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px 16px">';
|
||||
h+='<button class="btn primary" style="min-width:120px;justify-content:center" onclick="runActionConfirm(\''+a.id+'\',\''+esc(a.title)+'\',\''+a.kind+'\','+a.confirm_required+')">'+esc(a.title)+'</button>';
|
||||
h+='<span style="font-size:12px;color:var(--text3);flex:1">'+esc(a.kind||'')+'</span>';
|
||||
h+='<button class="btn" onclick="delAction(\''+a.id+'\')" title="Удалить" style="padding:4px 10px">✕</button>';
|
||||
h+='</div>';
|
||||
}
|
||||
h+='</div>';setCnt(h);
|
||||
}catch(e){E('Ошибка')}
|
||||
}
|
||||
|
||||
const AL={open_url:'URL',open_file:'Файл',open_folder:'Папка',run_command:'Команда',run_script:'Скрипт',open_terminal:'Терминал',launch_app:'Приложение'};
|
||||
function runActionConfirm(id,title,kind,confirm){
|
||||
if(!confirm){runActionExec(id);return}
|
||||
const lbl=AL[kind]||kind;
|
||||
G('ed-crumb').textContent='Действие: '+title;
|
||||
G('ed-title').textContent='Подтверждение';
|
||||
G('ed-ta').value='Тип: '+lbl+'\n\nВыполнить действие «'+title+'»?';
|
||||
G('ed').style.display='flex';
|
||||
editId='__action__'+id;
|
||||
}
|
||||
async function runActionExec(id){
|
||||
if(id.startsWith('__action__'))id=id.slice(10);
|
||||
try{
|
||||
const r=await api('/api/actions/'+id,{method:'POST',body:'{}'});
|
||||
closeED();
|
||||
if(r&&r.output)alert(r.output);
|
||||
}catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
async function delAction(id){
|
||||
if(!confirm('Удалить действие?'))return;
|
||||
try{
|
||||
await api('/api/actions/'+id,{method:'DELETE'});
|
||||
if(sel.kind==='node')loadNodeActions(sel.nodeId);
|
||||
}catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════
|
||||
EDITOR
|
||||
════════════════════════════════════════════ */
|
||||
@@ -571,7 +613,16 @@ async function openNT(id){editId=id;
|
||||
catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
function closeED(){G('ed').style.display='none';editId=''}
|
||||
async function saveNT(){if(!editId)return;try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();}catch(e){alert('Ошибка: '+e.message)}}
|
||||
async function saveNT(){
|
||||
if(!editId)return;
|
||||
if(editId.startsWith('__action__')){
|
||||
G('ed').style.display='none';
|
||||
await runActionExec(editId);
|
||||
editId='';
|
||||
return;
|
||||
}
|
||||
try{await api('/api/notes/'+editId,{method:'PUT',body:JSON.stringify({content:G('ed-ta').value})});closeED();}catch(e){alert('Ошибка: '+e.message)}
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════
|
||||
MODALS
|
||||
|
||||
+64
-1
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
@@ -22,6 +23,7 @@ type Server struct {
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
notes *notes.Service
|
||||
actions *actions.Service
|
||||
srv *http.Server
|
||||
listener net.Listener
|
||||
port int
|
||||
@@ -32,9 +34,10 @@ func NewServer(db *storage.DB, vaultRoot string) *Server {
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, vaultRoot)
|
||||
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
return &Server{
|
||||
db: db, vaultRoot: vaultRoot,
|
||||
nodes: nodeRepo, files: fileSvc, notes: noteSvc,
|
||||
nodes: nodeRepo, files: fileSvc, notes: noteSvc, actions: actionSvc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +48,7 @@ func (s *Server) Start() (string, error) {
|
||||
mux.HandleFunc("/api/nodes/", s.handleNodeDetail)
|
||||
mux.HandleFunc("/api/notes/", s.handleNotes)
|
||||
mux.HandleFunc("/api/files/", s.handleFiles)
|
||||
mux.HandleFunc("/api/actions/", s.handleActions)
|
||||
mux.HandleFunc("/api/search", s.handleSearch)
|
||||
mux.HandleFunc("/", s.handleStatic)
|
||||
|
||||
@@ -236,6 +240,65 @@ func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET/POST/DELETE /api/actions/{id} GET /api/actions?node=ID
|
||||
func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/actions/")
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
if path != "" {
|
||||
rec, err := s.actions.Get(path)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, rec)
|
||||
return
|
||||
}
|
||||
nodeID := r.URL.Query().Get("node")
|
||||
if nodeID == "" {
|
||||
jsonOK(w, []interface{}{})
|
||||
return
|
||||
}
|
||||
list, err := s.actions.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, list)
|
||||
case "POST":
|
||||
var req struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Command string `json:"command"`
|
||||
URL string `json:"url"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
Args []string `json:"args"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Capture bool `json:"capture"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
rec, err := s.actions.Create(req.NodeID, req.Kind, req.Title, req.Command, req.WorkingDir, req.URL, req.Args, req.Confirm, req.Capture)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, rec)
|
||||
case "DELETE":
|
||||
if path == "" {
|
||||
jsonErr(w, 400, "id required")
|
||||
return
|
||||
}
|
||||
if err := s.actions.Delete(path); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "deleted"})
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/search?q=...
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.ToLower(r.URL.Query().Get("q"))
|
||||
|
||||
Reference in New Issue
Block a user