feat: add interactive inbox view

This commit is contained in:
mirivlad 2026-06-05 00:59:57 +08:00
parent 02d68ca3f4
commit 035f877280
13 changed files with 163 additions and 8 deletions

View File

@ -0,0 +1,20 @@
package main
func (a *App) ListInboxNodes() ([]NodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
list, err := a.nodes.ListInboxRoots(false)
if err != nil {
return nil, err
}
dtos := toNodeDTOs(list)
for i := range dtos {
n, err := a.nodes.CountChildren(dtos[i].ID, "case", "client", "project", "folder", "document", "recipe")
if err != nil {
return nil, err
}
dtos[i].HasChildren = n > 0
}
return dtos, nil
}

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-Cyhj7TEH.js"></script> <script type="module" crossorigin src="/assets/main-Wpp0QvRu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css"> <link rel="stylesheet" crossorigin href="/assets/main-xwXesvcm.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -0,0 +1,52 @@
package main
import "testing"
func TestListInboxNodesReturnsOnlyUnassignedRoots(t *testing.T) {
app, _ := setupTestApp(t)
unassigned, err := app.CreateNodeFromTemplate("", "Unassigned Root", "folder.default")
if err != nil {
t.Fatalf("create unassigned root: %v", err)
}
inbox, err := app.CreateNodeFromTemplate("", "Inbox Root", "folder.default")
if err != nil {
t.Fatalf("create inbox root: %v", err)
}
assigned, err := app.CreateNodeFromTemplate("", "Assigned Root", "folder.default")
if err != nil {
t.Fatalf("create assigned root: %v", err)
}
child, err := app.CreateNodeFromTemplate(unassigned.ID, "Nested Child", "folder.default")
if err != nil {
t.Fatalf("create child: %v", err)
}
if _, err := app.db.Exec(`UPDATE nodes SET section = 'inbox' WHERE id = ?`, inbox.ID); err != nil {
t.Fatalf("mark inbox: %v", err)
}
if _, err := app.db.Exec(`UPDATE nodes SET section = 'projects' WHERE id = ?`, assigned.ID); err != nil {
t.Fatalf("mark assigned: %v", err)
}
list, err := app.ListInboxNodes()
if err != nil {
t.Fatalf("ListInboxNodes: %v", err)
}
got := map[string]bool{}
for _, item := range list {
got[item.ID] = true
}
if !got[unassigned.ID] {
t.Fatal("unassigned root missing from inbox")
}
if !got[inbox.ID] {
t.Fatal("section=inbox root missing from inbox")
}
if got[assigned.ID] {
t.Fatal("assigned root should not be in inbox")
}
if got[child.ID] {
t.Fatal("nested child should not be in inbox")
}
}

View File

@ -91,6 +91,7 @@
let acceptingSuggestion = null let acceptingSuggestion = null
let suggestions = [] let suggestions = []
let suggestionCount = 0 let suggestionCount = 0
let inboxNodes = []
let showCreateNode = false let showCreateNode = false
let newNodeTitle = '' let newNodeTitle = ''
let createInNode = null let createInNode = null
@ -228,6 +229,7 @@
actions = [] actions = []
worklog = [] worklog = []
suggestions = [] suggestions = []
inboxNodes = []
showCreateNode = false showCreateNode = false
error = '' error = ''
todayDashboard = null todayDashboard = null
@ -241,6 +243,8 @@
todayDashboard = await wailsCall('ListTodayView') || { cases: [] } todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
suggestions = await wailsCall('GetSuggestions') || [] suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length suggestionCount = suggestions.length
} else if (id === 'inbox') {
inboxNodes = await wailsCall('ListInboxNodes') || []
} else if (id === 'journal') { } else if (id === 'journal') {
await loadJournal() await loadJournal()
} else if (id === 'activity') { } else if (id === 'activity') {
@ -251,6 +255,7 @@
} catch (e) { } catch (e) {
error = String(e) error = String(e)
todayDashboard = { cases: [] } todayDashboard = { cases: [] }
inboxNodes = []
activityFeed = [] activityFeed = []
} }
} }
@ -1987,6 +1992,37 @@
{/if} {/if}
</div> </div>
{:else if selectedSection === 'inbox'}
<div class="inbox-screen">
<div class="inbox-header">
<div>
<h2>{t('nav.inbox')}</h2>
<p>{t('inbox.subtitle')}</p>
</div>
<button class="btn btn-primary btn-sm" on:click={openCreateRoot}>+ {t('nav.createNode')}</button>
</div>
{#if inboxNodes.length === 0}
<div class="empty-state">
<p>{t('inbox.empty')}</p>
</div>
{:else}
<div class="inbox-list">
{#each inboxNodes as item}
<div class="inbox-item" role="button" tabindex="0" on:click={() => openNodeById(item.id)} on:keydown={(e) => e.key === 'Enter' && openNodeById(item.id)}>
<div class="inbox-item-main">
<span class="inbox-item-title">{item.title}</span>
<span class="inbox-item-meta">{nodeKindLabel(item.type)} · {formatDate(item.createdAt)}</span>
</div>
<div class="inbox-item-actions">
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeById(item.id)}>{t('common.open')}</button>
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if selectedSection === 'journal'} {:else if selectedSection === 'journal'}
<div class="journal-screen"> <div class="journal-screen">
<div class="journal-header"> <div class="journal-header">
@ -2693,6 +2729,19 @@
.suggestion-confidence.medium { color: #60a5fa; } .suggestion-confidence.medium { color: #60a5fa; }
.suggestion-confidence.high { color: #34d399; } .suggestion-confidence.high { color: #34d399; }
/* Inbox screen */
.inbox-screen { padding: 24px; overflow-y: auto; flex: 1; }
.inbox-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.inbox-header h2 { margin: 0 0 6px; }
.inbox-header p { margin: 0; color: #a0a0b8; font-size: 13px; }
.inbox-list { display: flex; flex-direction: column; gap: 8px; }
.inbox-item { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; cursor: pointer; }
.inbox-item:hover { border-color: #3a3a5c; background: #1e1e32; }
.inbox-item-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.inbox-item-title { color: #e4e4ef; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inbox-item-meta { color: #8888a0; font-size: 12px; }
.inbox-item-actions { display: flex; gap: 8px; flex-shrink: 0; }
/* Journal screen */ /* Journal screen */
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; } .journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
.journal-header { margin-bottom: 24px; } .journal-header { margin-bottom: 24px; }

View File

@ -23,6 +23,8 @@ export default {
'nav.createInside': 'Create inside', 'nav.createInside': 'Create inside',
'nav.createNode': 'Create element', 'nav.createNode': 'Create element',
'nav.moveToRoot': 'Move to root', 'nav.moveToRoot': 'Move to root',
'inbox.subtitle': 'Root items without an assigned section',
'inbox.empty': 'No unprocessed items',
'tab.overview': 'Overview', 'tab.overview': 'Overview',
'tab.notes': 'Notes', 'tab.notes': 'Notes',
'tab.files': 'Files', 'tab.files': 'Files',

View File

@ -24,6 +24,9 @@ export default {
'nav.createNode': 'Создать элемент', 'nav.createNode': 'Создать элемент',
'nav.moveToRoot': 'Переместить в корень', 'nav.moveToRoot': 'Переместить в корень',
'inbox.subtitle': 'Корневые элементы без назначенного раздела',
'inbox.empty': 'Неразобранных элементов нет',
'tab.overview': 'Обзор', 'tab.overview': 'Обзор',
'tab.notes': 'Заметки', 'tab.notes': 'Заметки',
'tab.files': 'Файлы', 'tab.files': 'Файлы',

View File

@ -78,6 +78,10 @@ export function ListWorklog(arg1) {
return window['go']['main']['App']['ListWorklog'](arg1); return window['go']['main']['App']['ListWorklog'](arg1);
} }
export function ListInboxNodes() {
return window['go']['main']['App']['ListInboxNodes']();
}
export function CreateWorklog(arg1, arg2, arg3) { export function CreateWorklog(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3); return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
} }

View File

@ -169,6 +169,23 @@ func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
return scanNodes(rows) return scanNodes(rows)
} }
// ListInboxRoots returns active root nodes that are not assigned to a specific section.
func (r *Repository) ListInboxRoots(includeDeleted bool) ([]Node, error) {
q := `SELECT ` + nodeColumns + ` FROM nodes
WHERE parent_id IS NULL AND COALESCE(section, '') IN ('', 'inbox')`
if !includeDeleted {
q += " AND deleted_at IS NULL"
}
q += " ORDER BY updated_at DESC, sort_order, title"
rows, err := r.db.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNodes(rows)
}
// CountChildren returns the number of non-deleted children for a parent, // CountChildren returns the number of non-deleted children for a parent,
// optionally filtered by one or more types. // optionally filtered by one or more types.
func (r *Repository) CountChildren(parentID string, types ...string) (int, error) { func (r *Repository) CountChildren(parentID string, types ...string) (int, error) {

View File

@ -133,6 +133,12 @@ async function runReadyScenario(cdp, url) {
await click(cdp, '.close-btn') await click(cdp, '.close-btn')
await waitForGone(cdp, '.settings-window') await waitForGone(cdp, '.settings-window')
await clickText(cdp, '.nav-item', 'Неразобранное')
await assertText(cdp, 'Неразобранное', 'inbox: system view opens')
await assertText(cdp, 'Inbox Smoke Item', 'inbox: unassigned item visible')
await clickText(cdp, '.inbox-item-actions .btn', 'Открыть')
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
await clickText(cdp, '.tree-label', 'Smoke Project') await clickText(cdp, '.tree-label', 'Smoke Project')
await waitForSelector(cdp, '.tabs') await waitForSelector(cdp, '.tabs')
await assertText(cdp, 'Smoke Project', 'node: selected project visible') await assertText(cdp, 'Smoke Project', 'node: selected project visible')
@ -565,6 +571,7 @@ function wailsMockSource() {
], ],
}, },
{ id: 'node-client', title: 'Smoke Client', type: 'client', section: 'clients', createdAt: now, has_children: false, children: [] }, { id: 'node-client', title: 'Smoke Client', type: 'client', section: 'clients', createdAt: now, has_children: false, children: [] },
{ id: 'node-inbox', title: 'Inbox Smoke Item', type: 'folder', section: '', createdAt: now, has_children: false, children: [] },
], ],
notes: { notes: {
'node-project': [{ id: 'note-1', title: 'Smoke note', createdAt: now }], 'node-project': [{ id: 'note-1', title: 'Smoke note', createdAt: now }],
@ -671,6 +678,7 @@ function wailsMockSource() {
], ],
ListWorkspaceTree: async () => clone(state.nodes), ListWorkspaceTree: async () => clone(state.nodes),
ListWorkspaceChildren: async (id) => clone(childrenOf(id)), ListWorkspaceChildren: async (id) => clone(childrenOf(id)),
ListInboxNodes: async () => clone(state.nodes.filter((node) => !node.parent_id && (!node.section || node.section === 'inbox'))),
ListEnabledTemplates: async () => clone(templates), ListEnabledTemplates: async () => clone(templates),
AllTemplates: async () => clone(templates), AllTemplates: async () => clone(templates),
SetTemplateEnabled: async () => true, SetTemplateEnabled: async () => true,
@ -679,9 +687,9 @@ function wailsMockSource() {
SearchNodes: async (query) => allNodes().filter((node) => node.title.toLowerCase().includes(String(query || '').toLowerCase())).map((node) => ({ id: node.id, title: node.title, path: '/Smoke/' + node.title })), SearchNodes: async (query) => allNodes().filter((node) => node.title.toLowerCase().includes(String(query || '').toLowerCase())).map((node) => ({ id: node.id, title: node.title, path: '/Smoke/' + node.title })),
CreateNodeFromTemplate: async (parentId, title, templateId) => { CreateNodeFromTemplate: async (parentId, title, templateId) => {
const id = 'node-created-' + Date.now(); const id = 'node-created-' + Date.now();
const node = { id, title, type: templateId?.split('.')[0] || 'folder', section: 'projects', parent_id: parentId || '', createdAt: now, has_children: false, children: [] }; const parent = parentId ? findNode(parentId) : null;
const node = { id, title, type: templateId?.split('.')[0] || 'folder', section: parent?.section || '', parent_id: parentId || '', createdAt: now, has_children: false, children: [] };
if (parentId) { if (parentId) {
const parent = findNode(parentId);
if (parent) { if (parent) {
parent.children = parent.children || []; parent.children = parent.children || [];
parent.children.push(node); parent.children.push(node);