Implement Acro workbench UI refresh

This commit is contained in:
2026-07-20 01:21:49 +08:00
parent 03a5c52b72
commit 0f2cbcb9ee
9 changed files with 874 additions and 125 deletions

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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' } } });

View 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>

View File

@@ -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>&#8592;</button>
<button aria-label="Forward" disabled>&#8594;</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>

View File

@@ -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>