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;
}
</style>
<script type="module" crossorigin src="/assets/main-Cyhj7TEH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css">
<script type="module" crossorigin src="/assets/main-Wpp0QvRu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-xwXesvcm.css">
</head>
<body>
<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 suggestions = []
let suggestionCount = 0
let inboxNodes = []
let showCreateNode = false
let newNodeTitle = ''
let createInNode = null
@ -228,6 +229,7 @@
actions = []
worklog = []
suggestions = []
inboxNodes = []
showCreateNode = false
error = ''
todayDashboard = null
@ -241,6 +243,8 @@
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length
} else if (id === 'inbox') {
inboxNodes = await wailsCall('ListInboxNodes') || []
} else if (id === 'journal') {
await loadJournal()
} else if (id === 'activity') {
@ -251,6 +255,7 @@
} catch (e) {
error = String(e)
todayDashboard = { cases: [] }
inboxNodes = []
activityFeed = []
}
}
@ -1987,6 +1992,37 @@
{/if}
</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'}
<div class="journal-screen">
<div class="journal-header">
@ -2693,6 +2729,19 @@
.suggestion-confidence.medium { color: #60a5fa; }
.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 { padding: 24px; overflow-y: auto; flex: 1; }
.journal-header { margin-bottom: 24px; }

View File

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

View File

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

View File

@ -78,6 +78,10 @@ export function ListWorklog(arg1) {
return window['go']['main']['App']['ListWorklog'](arg1);
}
export function ListInboxNodes() {
return window['go']['main']['App']['ListInboxNodes']();
}
export function 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)
}
// 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,
// optionally filtered by one or more types.
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 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 waitForSelector(cdp, '.tabs')
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-inbox', title: 'Inbox Smoke Item', type: 'folder', section: '', createdAt: now, has_children: false, children: [] },
],
notes: {
'node-project': [{ id: 'note-1', title: 'Smoke note', createdAt: now }],
@ -671,6 +678,7 @@ function wailsMockSource() {
],
ListWorkspaceTree: async () => clone(state.nodes),
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),
AllTemplates: async () => clone(templates),
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 })),
CreateNodeFromTemplate: async (parentId, title, templateId) => {
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) {
const parent = findNode(parentId);
if (parent) {
parent.children = parent.children || [];
parent.children.push(node);