Implement Acro workbench UI refresh
This commit is contained in:
@@ -99,13 +99,24 @@
|
||||
|
||||
<main class="login-page">
|
||||
<section class="login-panel" aria-labelledby="login-title">
|
||||
<div class="login-brand" aria-hidden="true">SA</div>
|
||||
<div class="login-heading">
|
||||
<p>Private workbench</p>
|
||||
<h1 id="login-title">SenlinAI Workbench</h1>
|
||||
<div class="login-brand-panel">
|
||||
<div class="login-brand" aria-hidden="true">S</div>
|
||||
<h1>SenlinAI</h1>
|
||||
<p>私有部署 · 安全可控的智能协作平台</p>
|
||||
<small>知识沉淀 · 团队协作 · AI 助理</small>
|
||||
<nav class="login-links" aria-label="Login resources">
|
||||
<a href="/" on:click|preventDefault>隐私政策</a>
|
||||
<a href="/" on:click|preventDefault>服务协议</a>
|
||||
<a href="/" on:click|preventDefault>关于 SenlinAI</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
|
||||
<div class="login-heading">
|
||||
<p>登录到您的 SenlinAI</p>
|
||||
<h2 id="login-title">连接私有工作台</h2>
|
||||
</div>
|
||||
|
||||
<label for="server-address">Server IP or domain</label>
|
||||
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
||||
|
||||
@@ -130,5 +141,15 @@
|
||||
<button type="submit">Log in</button>
|
||||
<p class="login-note">The server address is remembered on this device.</p>
|
||||
</form>
|
||||
|
||||
<aside class="login-value-panel" aria-label="Product values">
|
||||
<h2>安全 · 专注 · 高效</h2>
|
||||
<p>SenlinAI 是面向知识工作者与内部团队的私有部署工作台,帮助团队在统一空间中收集信息、整理知识、协同决策。</p>
|
||||
<ul>
|
||||
<li><strong>私有部署,数据自有</strong><span>所有数据与模型运行在您的环境中。</span></li>
|
||||
<li><strong>知识沉淀,结构清晰</strong><span>从收集到整理,形成可复用的团队知识。</span></li>
|
||||
<li><strong>协同高效,聚焦成果</strong><span>围绕项目与任务协同,减少沟通成本。</span></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
title={project.name}
|
||||
on:click={() => onSelectProject(project.id)}
|
||||
>
|
||||
<span>{project.initials}</span>
|
||||
<span class="project-initials">{project.initials}</span>
|
||||
{#if project.unreadCount}
|
||||
<small>{project.unreadCount}</small>
|
||||
<small class:urgent={project.unreadCount > 20} aria-label={`${project.name} updates ${project.unreadCount}`}>{project.unreadCount}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
import ProjectRail from './ProjectRail.svelte';
|
||||
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
||||
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
||||
import WorkspaceStatusBar from './WorkspaceStatusBar.svelte';
|
||||
import type { InspectorItem, WorkTask } from './types';
|
||||
|
||||
export let currentUser: { account: string };
|
||||
|
||||
let theme: 'light' | 'dark' = 'light';
|
||||
let selectedProjectID = workbenchProjects[0].id;
|
||||
let selectedItem: InspectorItem | null = null;
|
||||
let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries(
|
||||
@@ -39,10 +41,14 @@
|
||||
[selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task),
|
||||
};
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'light' ? 'dark' : 'light';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="workbench-shell">
|
||||
<WorkspaceTopbar account={currentUser.account} />
|
||||
<main class="workbench-shell" aria-label="SenlinAI workbench" data-theme={theme}>
|
||||
<WorkspaceTopbar {theme} onToggleTheme={toggleTheme} />
|
||||
<div class="workbench-body">
|
||||
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
||||
<ProjectChannelSidebar
|
||||
@@ -58,4 +64,5 @@
|
||||
</section>
|
||||
<ObjectInspector item={selectedItem} />
|
||||
</div>
|
||||
<WorkspaceStatusBar account={currentUser.account} />
|
||||
</main>
|
||||
|
||||
@@ -9,12 +9,51 @@ describe('ProjectWorkbench', () => {
|
||||
|
||||
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByLabelText('Project A1 updates 36')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Global search')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Global search')).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'Search workspace' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Switch to dark mode' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pins user actions and task status to the page bottom bar', () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
const statusBar = screen.getByLabelText('Workspace status');
|
||||
|
||||
expect(within(statusBar).getByText('david@example.com')).toBeInTheDocument();
|
||||
expect(within(statusBar).getByRole('button', { name: 'Friends' })).toBeInTheDocument();
|
||||
expect(within(statusBar).getByRole('button', { name: 'Direct messages' })).toBeInTheDocument();
|
||||
expect(within(statusBar).getByRole('button', { name: 'Settings' })).toBeInTheDocument();
|
||||
expect(within(statusBar).getByText('Sync complete')).toBeInTheDocument();
|
||||
expect(within(statusBar).getByText('3 tasks running')).toBeInTheDocument();
|
||||
expect(within(statusBar).getByText('AI queue idle')).toBeInTheDocument();
|
||||
expect(within(statusBar).getByText('Draft saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows overview work queues below the metric cards', () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Inbox 待处理' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '进行中的任务' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'AI 会话' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Collect competitor pricing notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Confirm homepage information architecture')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles between light and dark workbench palettes', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
const shell = screen.getByLabelText('SenlinAI workbench');
|
||||
|
||||
expect(shell).toHaveAttribute('data-theme', 'light');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Switch to dark mode' }));
|
||||
|
||||
expect(shell).toHaveAttribute('data-theme', 'dark');
|
||||
expect(screen.getByRole('button', { name: 'Switch to light mode' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes channels when switching projects', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
|
||||
30
apps/web/src/features/workbench/WorkspaceStatusBar.svelte
Normal file
30
apps/web/src/features/workbench/WorkspaceStatusBar.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
export let account: string;
|
||||
|
||||
$: userInitials = account.slice(0, 2).toUpperCase();
|
||||
</script>
|
||||
|
||||
<footer class="workspace-statusbar" aria-label="Workspace status">
|
||||
<section class="statusbar-user" aria-label="Current user">
|
||||
<span class="user-avatar" aria-hidden="true">{userInitials}</span>
|
||||
<span class="user-meta">
|
||||
<strong>{account}</strong>
|
||||
<small>在线</small>
|
||||
</span>
|
||||
<button type="button" aria-label="Friends">好友</button>
|
||||
<button type="button" aria-label="Direct messages">私信</button>
|
||||
<button type="button" aria-label="Settings">设置</button>
|
||||
</section>
|
||||
|
||||
<section class="statusbar-tasks" aria-label="Task status">
|
||||
<span>Sync complete</span>
|
||||
<span>3 tasks running</span>
|
||||
<span>AI queue idle</span>
|
||||
<span>Draft saved</span>
|
||||
</section>
|
||||
|
||||
<section class="statusbar-system" aria-label="System status">
|
||||
<span>本地存储 152GB 可用</span>
|
||||
<span>系统健康 正常</span>
|
||||
</section>
|
||||
</footer>
|
||||
@@ -1,16 +1,25 @@
|
||||
<script lang="ts">
|
||||
export let account: string;
|
||||
export let theme: 'light' | 'dark' = 'light';
|
||||
export let onToggleTheme: () => void = () => {};
|
||||
</script>
|
||||
|
||||
<header class="workspace-topbar">
|
||||
<div class="brand-mark">SenlinAI</div>
|
||||
<label class="search-box">
|
||||
<span>Search</span>
|
||||
<input aria-label="Global search" title="Global search is unavailable in this preview" placeholder="Search projects, tasks, notes, AI sessions" disabled />
|
||||
</label>
|
||||
<div class="brand-mark" aria-label="SenlinAI">
|
||||
<span class="brand-logo">S</span>
|
||||
<span>SenlinAI</span>
|
||||
</div>
|
||||
<form class="search-box" role="search" aria-label="Workspace search" on:submit|preventDefault>
|
||||
<label class="sr-only" for="global-search">Search</label>
|
||||
<input id="global-search" aria-label="Global search" placeholder="搜索项目、频道、文档、任务、联系人..." />
|
||||
<button type="submit" aria-label="Search workspace">搜索</button>
|
||||
</form>
|
||||
<div class="topbar-actions">
|
||||
<button aria-label="Back" disabled>←</button>
|
||||
<button aria-label="Forward" disabled>→</button>
|
||||
<button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button>
|
||||
<button class="theme-toggle" type="button" aria-label={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'} on:click={onToggleTheme}>
|
||||
<span aria-hidden="true">{theme === 'light' ? '☾' : '☀'}</span>
|
||||
</button>
|
||||
<button aria-label="Back" disabled>←</button>
|
||||
<button aria-label="Forward" disabled>→</button>
|
||||
<button aria-label="Notifications" disabled>!</button>
|
||||
<button aria-label="Help" disabled>?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -15,8 +15,14 @@
|
||||
|
||||
<section class="channel-page overview-channel">
|
||||
<header class="channel-header">
|
||||
<p>{workspace.project.name}</p>
|
||||
<h1>Overview</h1>
|
||||
<div>
|
||||
<p>{workspace.project.name}</p>
|
||||
<h1>Overview</h1>
|
||||
</div>
|
||||
<div class="overview-tools" aria-label="Overview filters">
|
||||
<span>2026-07-20</span>
|
||||
<button type="button" aria-label="More overview actions">...</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-list">
|
||||
{#each metrics(workspace) as metric}
|
||||
@@ -26,4 +32,117 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<section class="overview-section">
|
||||
<header>
|
||||
<h2>Inbox 待处理</h2>
|
||||
<a href="/" on:click|preventDefault>查看全部</a>
|
||||
</header>
|
||||
<div class="overview-rows">
|
||||
{#each workspace.inbox as message}
|
||||
<button
|
||||
type="button"
|
||||
class="overview-row"
|
||||
on:click={() => onInspect({
|
||||
title: message.title,
|
||||
type: 'Inbox message',
|
||||
description: message.summary,
|
||||
properties: [
|
||||
{ label: 'Source', value: message.source },
|
||||
{ label: 'Status', value: message.status },
|
||||
{ label: 'Tag', value: message.tag },
|
||||
],
|
||||
})}
|
||||
>
|
||||
<span>{message.title}</span>
|
||||
<small>来源 {message.source} · #{message.tag}</small>
|
||||
<time>{message.time}</time>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-section">
|
||||
<header>
|
||||
<h2>进行中的任务</h2>
|
||||
<a href="/" on:click|preventDefault>查看全部</a>
|
||||
</header>
|
||||
<div class="overview-rows">
|
||||
{#each workspace.tasks as task}
|
||||
<button
|
||||
type="button"
|
||||
class="overview-row"
|
||||
on:click={() => onInspect({
|
||||
title: task.title,
|
||||
type: 'Task',
|
||||
description: task.summary,
|
||||
properties: [
|
||||
{ label: 'Status', value: task.completed ? 'Done' : 'Open' },
|
||||
{ label: 'Owner', value: task.owner },
|
||||
{ label: 'Due', value: task.due },
|
||||
],
|
||||
})}
|
||||
>
|
||||
<span>{task.title}</span>
|
||||
<small>负责人 {task.owner} · #{task.tag}</small>
|
||||
<time>{task.due}</time>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-section">
|
||||
<header>
|
||||
<h2>AI 会话</h2>
|
||||
<a href="/" on:click|preventDefault>查看全部</a>
|
||||
</header>
|
||||
<div class="overview-rows">
|
||||
{#each workspace.aiSessions as session}
|
||||
<button
|
||||
type="button"
|
||||
class="overview-row"
|
||||
on:click={() => onInspect({
|
||||
title: session.title,
|
||||
type: 'AI session',
|
||||
description: session.summary,
|
||||
properties: session.references.map((reference) => ({ label: 'Reference', value: reference })),
|
||||
})}
|
||||
>
|
||||
<span>{session.title}</span>
|
||||
<small>{session.summary}</small>
|
||||
<time>{session.updatedAt}</time>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-section">
|
||||
<header>
|
||||
<h2>最近笔记资料</h2>
|
||||
<a href="/" on:click|preventDefault>查看全部</a>
|
||||
</header>
|
||||
<div class="overview-rows">
|
||||
{#each workspace.notesSources as item}
|
||||
<button
|
||||
type="button"
|
||||
class="overview-row"
|
||||
on:click={() => onInspect({
|
||||
title: item.title,
|
||||
type: item.kind,
|
||||
description: `${item.source} · ${item.tag}`,
|
||||
properties: [
|
||||
{ label: 'Updated', value: item.updatedAt },
|
||||
{ label: 'Tag', value: item.tag },
|
||||
],
|
||||
})}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
<small>{item.source} · #{item.tag}</small>
|
||||
<time>{item.updatedAt}</time>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user