feat: add full workbench login page

This commit is contained in:
2026-07-19 19:01:27 +08:00
parent 4fc9271896
commit 5b95668945
3 changed files with 110 additions and 81 deletions

View File

@@ -1,76 +1,21 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import ProjectDashboard from '../features/projects/ProjectDashboard.svelte';
import ServerLogin from '../features/auth/ServerLogin.svelte'; import ServerLogin from '../features/auth/ServerLogin.svelte';
import SuggestionList from '../features/inbox/SuggestionList.svelte';
import type { Suggestion } from '../features/inbox/types';
import { createApi, type ProjectDashboardSummary } from '../lib/api';
const emptyDashboard: ProjectDashboardSummary = {
project_id: 1,
pending_inbox_count: 0,
open_task_count: 0,
recent_note_count: 0,
recent_session_count: 0,
};
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080'; let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
let dashboard = emptyDashboard; let currentUser: { account: string } | null = null;
let connectionStatus = '未连接';
let selectedSuggestionCount = 0;
const sampleSuggestions: Suggestion[] = [ function handleLogin(detail: { apiBase: string; account: string }) {
{ apiBase = detail.apiBase;
kind: 'task',
title: '跟进报价',
body: '从项目 inbox 确认后创建任务,并保留来源记录。',
},
{
kind: 'note',
title: '客户背景',
body: '把对话中的背景信息沉淀成项目笔记。',
},
];
onMount(() => {
void loadDashboard();
});
function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
apiBase = event.detail.apiBase;
localStorage.setItem('apiBase', apiBase); localStorage.setItem('apiBase', apiBase);
void loadDashboard(); currentUser = { account: detail.account };
}
async function loadDashboard() {
connectionStatus = '连接中';
try {
dashboard = await createApi(apiBase).getProjectDashboard(1);
connectionStatus = '已连接';
} catch {
dashboard = emptyDashboard;
connectionStatus = '无法连接服务器';
}
}
function handleConfirm(selected: Suggestion[]) {
selectedSuggestionCount = selected.length;
} }
</script> </script>
<main class="shell"> {#if currentUser}
<aside class="sidebar"> <main class="workbench-placeholder" aria-label="Project workbench">
<h1>项目工作台</h1> <h1>SenlinAI Workbench</h1>
<ServerLogin {apiBase} on:serverChange={handleServerChange} /> <p>Signed in as {currentUser.account}</p>
<p class="connection-status" aria-live="polite">{connectionStatus}</p> </main>
</aside> {:else}
<ServerLogin {apiBase} onLogin={handleLogin} />
<section class="workspace"> {/if}
<ProjectDashboard summary={dashboard} />
<section class="inbox-review" aria-label="项目 inbox">
<h2>AI 整理建议</h2>
<SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} />
<p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount}</p>
</section>
</section>
</main>

View File

@@ -1,16 +1,11 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte';
export let apiBase: string; export let apiBase: string;
export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>();
let server = apiBase; let server = apiBase;
let account = '';
function saveServer() { let password = '';
const normalized = normalizeServer(server); let error = '';
server = normalized;
dispatch('serverChange', { apiBase: normalized });
}
function normalizeServer(value: string) { function normalizeServer(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -19,10 +14,50 @@
} }
return `http://${trimmed}`; return `http://${trimmed}`;
} }
function submitLogin() {
error = '';
if (!account.trim() || !password.trim()) {
error = 'Enter an account and password.';
return;
}
const normalized = normalizeServer(server);
server = normalized;
onLogin({ apiBase: normalized, account: account.trim() });
}
</script> </script>
<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}> <main class="login-page">
<label for="server-address">服务器 IP 或域名</label> <section class="login-panel" aria-labelledby="login-title">
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" /> <div class="login-brand" aria-hidden="true">SA</div>
<button type="submit">保存服务器</button> <div class="login-heading">
</form> <p>Private workbench</p>
<h1 id="login-title">SenlinAI Workbench</h1>
</div>
<form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
<label for="server-address">Server IP or domain</label>
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
<label for="login-account">Email or username</label>
<input id="login-account" name="account" bind:value={account} autocomplete="username" />
<label for="login-password">Password</label>
<input
id="login-password"
name="password"
type="password"
bind:value={password}
autocomplete="current-password"
/>
{#if error}
<p class="form-error" aria-live="polite">{error}</p>
{/if}
<button type="submit">Log in</button>
<p class="login-note">The server address is remembered on this device.</p>
</form>
</section>
</main>

View File

@@ -0,0 +1,49 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it, vi } from 'vitest';
import ServerLogin from './ServerLogin.svelte';
describe('ServerLogin', () => {
it('renders server, account, and password fields', () => {
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
});
it('normalizes server address and emits login details', async () => {
const onLogin = vi.fn();
render(ServerLogin, {
props: {
apiBase: 'localhost:8080',
onLogin,
},
});
await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
target: { value: '10.0.0.12:8080' },
});
await fireEvent.input(screen.getByLabelText('Email or username'), {
target: { value: 'david@example.com' },
});
await fireEvent.input(screen.getByLabelText('Password'), {
target: { value: 'secret' },
});
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(onLogin).toHaveBeenCalledWith({
apiBase: 'http://10.0.0.12:8080',
account: 'david@example.com',
});
});
it('shows an error when account or password is missing', async () => {
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
});
});