feat: simplify inbox actions and group task tabs

This commit is contained in:
mirivlad 2026-06-05 12:32:36 +08:00
parent 6d15639b41
commit 23f517dee3
7 changed files with 59 additions and 21 deletions

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

@ -19,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-D6zAtuqe.js"></script> <script type="module" crossorigin src="/assets/main-BQHjHDrT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CtRnbH6M.css"> <link rel="stylesheet" crossorigin href="/assets/main-DfazBFdN.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -177,11 +177,11 @@
{ id: 'overview', label: t('tab.overview') }, { id: 'overview', label: t('tab.overview') },
{ id: 'notes', label: t('tab.notes') }, { id: 'notes', label: t('tab.notes') },
{ id: 'files', label: t('tab.files') }, { id: 'files', label: t('tab.files') },
{ id: 'inbox', label: t('tab.inbox') },
{ id: 'links', label: t('tab.links') }, { id: 'links', label: t('tab.links') },
{ id: 'actions', label: t('tab.actions') }, { id: 'actions', label: t('tab.actions') },
{ id: 'worklog', label: t('tab.worklog') }, { id: 'activity', label: t('tab.activity'), group: 'service' },
{ id: 'activity', label: t('tab.activity') }, { id: 'worklog', label: t('tab.worklog'), group: 'service' },
{ id: 'inbox', label: t('tab.inbox'), group: 'service' },
] ]
let unlistenDrop = null let unlistenDrop = null
@ -2197,6 +2197,9 @@
<!-- Tabs --> <!-- Tabs -->
<div class="tabs"> <div class="tabs">
{#each tabs as tab} {#each tabs as tab}
{#if tab.id === 'activity'}
<span class="tab-separator" aria-hidden="true"></span>
{/if}
<button class="tab" class:active={activeTab === tab.id} on:click={() => { activeTab = tab.id; if (tab.id === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id) }}>{tab.label}</button> <button class="tab" class:active={activeTab === tab.id} on:click={() => { activeTab = tab.id; if (tab.id === 'files' && selectedNode && fileItems.length === 0 && !currentFolderId) loadFolder(selectedNode.id) }}>{tab.label}</button>
{/each} {/each}
</div> </div>
@ -2372,9 +2375,15 @@
<span class="inbox-item-meta">{inboxMetaText(item)}</span> <span class="inbox-item-meta">{inboxMetaText(item)}</span>
</div> </div>
<div class="inbox-item-actions"> <div class="inbox-item-actions">
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => resolveInboxHere(item)}>{t('inbox.keepHere')}</button> <button class="inbox-icon-btn inbox-icon-btn-primary" title={t('inbox.keepHere')} aria-label={t('inbox.keepHere')} on:click|stopPropagation={() => resolveInboxHere(item)}>
<button class="btn btn-sm" on:click|stopPropagation={() => openAssignInbox(item)}>{t('inbox.assign')}</button> <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="M20 6 9 17l-5-5"/></svg>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => confirmDeleteInbox(item)}>{t('common.delete')}</button> </button>
<button class="inbox-icon-btn" title={t('inbox.assign')} aria-label={t('inbox.assign')} on:click|stopPropagation={() => openAssignInbox(item)}>
<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="M14 3h7v7"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>
</button>
<button class="inbox-icon-btn inbox-icon-btn-danger" title={t('common.delete')} aria-label={t('common.delete')} on:click|stopPropagation={() => confirmDeleteInbox(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div> </div>
</div> </div>
{/each} {/each}
@ -2600,14 +2609,22 @@
</div> </div>
<div class="inbox-item-actions"> <div class="inbox-item-actions">
{#if item.suggestedTargetNodeId} {#if item.suggestedTargetNodeId}
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => resolveInboxHere(item)}>{t('inbox.keepHere')}</button> <button class="inbox-icon-btn inbox-icon-btn-primary" title={t('inbox.keepHere')} aria-label={t('inbox.keepHere')} on:click|stopPropagation={() => resolveInboxHere(item)}>
<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="M20 6 9 17l-5-5"/></svg>
</button>
{/if} {/if}
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => openAssignInbox(item)}>{t('inbox.assign')}</button> <button class="inbox-icon-btn" title={t('inbox.assign')} aria-label={t('inbox.assign')} on:click|stopPropagation={() => openAssignInbox(item)}>
<button class="btn btn-sm" on:click|stopPropagation={() => openInboxArtifact(item)}>{t('common.open')}</button> <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="M14 3h7v7"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>
</button>
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click|stopPropagation={() => openInboxArtifact(item)}>
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
</button>
{#if canShowInboxArtifactInFolder(item)} {#if canShowInboxArtifactInFolder(item)}
<button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button> <button class="btn btn-sm" on:click|stopPropagation={() => openNodeFolder(item)}>{t('file.showInExplorer')}</button>
{/if} {/if}
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => confirmDeleteInbox(item)}>{t('common.delete')}</button> <button class="inbox-icon-btn inbox-icon-btn-danger" title={t('common.delete')} aria-label={t('common.delete')} on:click|stopPropagation={() => confirmDeleteInbox(item)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div> </div>
</div> </div>
{/each} {/each}
@ -3372,10 +3389,11 @@
.dismiss-btn:hover { color: #ff4444; } .dismiss-btn:hover { color: #ff4444; }
/* Tabs */ /* Tabs */
.tabs { display: flex; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; padding: 0 24px; } .tabs { display: flex; align-items: stretch; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; padding: 0 24px; }
.tab { padding: 10px 16px; border: none; background: none; color: #888; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; font-family: inherit; } .tab { padding: 10px 16px; border: none; background: none; color: #888; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; font-family: inherit; }
.tab:hover { color: #a5b4fc; } .tab:hover { color: #a5b4fc; }
.tab.active { color: #e4e4ef; border-bottom-color: #818cf8; background: rgba(99,102,241,0.12); font-weight: 600; } .tab.active { color: #e4e4ef; border-bottom-color: #818cf8; background: rgba(99,102,241,0.12); font-weight: 600; }
.tab-separator { width: 1px; height: 22px; margin: 8px 10px 0; background: #2a2a3c; flex-shrink: 0; }
/* Tab content */ /* Tab content */
.tab-content { flex: 1; overflow-y: auto; } .tab-content { flex: 1; overflow-y: auto; }
@ -3462,7 +3480,13 @@
.inbox-item-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } .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-title { color: #e4e4ef; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inbox-item-meta { color: #8888a0; font-size: 12px; } .inbox-item-meta { color: #8888a0; font-size: 12px; }
.inbox-item-actions { display: flex; gap: 8px; flex-shrink: 0; } .inbox-item-actions { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
.inbox-icon-btn { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #2a2a3c; border-radius: 6px; background: #13131f; color: #a0a0b8; cursor: pointer; transition: color 0.12s, border-color 0.12s, background 0.12s; }
.inbox-icon-btn:hover { color: #e4e4ef; border-color: #3a3a5c; background: #222238; }
.inbox-icon-btn-primary { color: #a5b4fc; border-color: #34346a; }
.inbox-icon-btn-primary:hover { color: #fff; border-color: #6366f1; background: #272750; }
.inbox-icon-btn-danger { color: #f87171; border-color: #4a252c; }
.inbox-icon-btn-danger:hover { color: #fff; border-color: #dc2626; background: #3a1f24; }
.inbox-tab { padding: 24px; } .inbox-tab { padding: 24px; }
/* Links tab */ /* Links tab */

View File

@ -145,6 +145,14 @@ async function runReadyScenario(cdp, url) {
await assertText(cdp, 'example.test', 'inbox: clipboard URL captured') await assertText(cdp, 'example.test', 'inbox: clipboard URL captured')
await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible') await assertText(cdp, 'Ссылка', 'inbox: clipboard URL kind visible')
await assertInboxItemButtonAbsent(cdp, 'example.test', 'Показать в проводнике', 'inbox: link has no explorer action') await assertInboxItemButtonAbsent(cdp, 'example.test', 'Показать в проводнике', 'inbox: link has no explorer action')
await assertEval(cdp, `
(() => {
const row = [...document.querySelectorAll('.inbox-item')].find((el) => el.innerText.includes('example.test'));
if (!row) return false;
const iconButtons = [...row.querySelectorAll('.inbox-icon-btn')];
return iconButtons.length >= 3 && iconButtons.every((btn) => btn.title && btn.innerText.trim() === '');
})()
`, 'inbox: frequent actions are icon buttons with titles')
await clickInboxItemButton(cdp, 'example.test', 'Открыть') await clickInboxItemButton(cdp, 'example.test', 'Открыть')
await assertEval(cdp, `window.__VERSTAK_GUI_SMOKE__.state.openedUrls.includes('https://example.test/from-clipboard')`, 'inbox: open URL launches external URL') await assertEval(cdp, `window.__VERSTAK_GUI_SMOKE__.state.openedUrls.includes('https://example.test/from-clipboard')`, 'inbox: open URL launches external URL')
await clickInboxItemButton(cdp, 'example.test', 'Разложить') await clickInboxItemButton(cdp, 'example.test', 'Разложить')
@ -202,6 +210,8 @@ async function runReadyScenario(cdp, url) {
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')
await assertEval(cdp, `document.querySelectorAll('.tab').length === 8`, 'node: all primary tabs rendered') await assertEval(cdp, `document.querySelectorAll('.tab').length === 8`, 'node: all primary tabs rendered')
await assertEval(cdp, `[...document.querySelectorAll('.tab')].map((el) => el.innerText.trim()).join('|') === 'Обзор|Заметки|Файлы|Ссылки|Действия|Активность|Журнал|Неразобранное'`, 'node: tabs use content-first order')
await assertEval(cdp, `document.querySelector('.tab-separator') !== null`, 'node: service tabs are visually separated')
await screenshot(cdp, 'node-overview.png') await screenshot(cdp, 'node-overview.png')
await clickText(cdp, '.tab', 'Заметки') await clickText(cdp, '.tab', 'Заметки')
@ -492,7 +502,9 @@ async function clickInboxItemButton(cdp, title, buttonText) {
.find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(title)})); .find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(title)}));
if (!row) return false; if (!row) return false;
const btn = [...row.querySelectorAll('button')] const btn = [...row.querySelectorAll('button')]
.find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(buttonText)})); .find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(buttonText)}) ||
norm(node.getAttribute('title')).includes(${JSON.stringify(buttonText)}) ||
norm(node.getAttribute('aria-label')).includes(${JSON.stringify(buttonText)}));
if (!btn) return false; if (!btn) return false;
btn.click(); btn.click();
return true; return true;
@ -510,7 +522,9 @@ async function assertInboxItemButtonAbsent(cdp, title, buttonText, label) {
.find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(title)})); .find((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(title)}));
if (!row) return false; if (!row) return false;
return ![...row.querySelectorAll('button')] return ![...row.querySelectorAll('button')]
.some((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(buttonText)})); .some((node) => norm(node.innerText || node.textContent).includes(${JSON.stringify(buttonText)}) ||
norm(node.getAttribute('title')).includes(${JSON.stringify(buttonText)}) ||
norm(node.getAttribute('aria-label')).includes(${JSON.stringify(buttonText)}));
})() })()
`, label) `, label)
} }
@ -848,7 +862,7 @@ function wailsMockSource() {
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : ''; return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname ? raw : '';
} catch { } catch {
try { try {
const withScheme = `https://${raw}`; const withScheme = 'https://' + raw;
const parsed = new URL(withScheme); const parsed = new URL(withScheme);
return parsed.hostname && parsed.hostname.includes('.') ? withScheme : ''; return parsed.hostname && parsed.hostname.includes('.') ? withScheme : '';
} catch { } catch {