243 lines
8.2 KiB
Markdown
243 lines
8.2 KiB
Markdown
# Review package Task 1
|
|
|
|
## Commits
|
|
5b95668 feat: add full workbench login page
|
|
|
|
## Stat
|
|
apps/web/src/app/App.svelte | 79 ++++----------------------
|
|
apps/web/src/features/auth/ServerLogin.svelte | 63 +++++++++++++++-----
|
|
apps/web/src/features/auth/ServerLogin.test.ts | 49 ++++++++++++++++
|
|
3 files changed, 110 insertions(+), 81 deletions(-)
|
|
|
|
## Diff
|
|
diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
|
|
index a4c6389..a5d8dce 100644
|
|
--- a/apps/web/src/app/App.svelte
|
|
+++ b/apps/web/src/app/App.svelte
|
|
@@ -1,76 +1,21 @@
|
|
<script lang="ts">
|
|
- import { onMount } from 'svelte';
|
|
- import ProjectDashboard from '../features/projects/ProjectDashboard.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 dashboard = emptyDashboard;
|
|
- let connectionStatus = '未连接';
|
|
- let selectedSuggestionCount = 0;
|
|
-
|
|
- const sampleSuggestions: Suggestion[] = [
|
|
- {
|
|
- kind: 'task',
|
|
- title: '跟进报价',
|
|
- body: '从项目 inbox 确认后创建任务,并保留来源记录。',
|
|
- },
|
|
- {
|
|
- kind: 'note',
|
|
- title: '客户背景',
|
|
- body: '把对话中的背景信息沉淀成项目笔记。',
|
|
- },
|
|
- ];
|
|
+ let currentUser: { account: string } | null = null;
|
|
|
|
- onMount(() => {
|
|
- void loadDashboard();
|
|
- });
|
|
-
|
|
- function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
|
|
- apiBase = event.detail.apiBase;
|
|
+ function handleLogin(detail: { apiBase: string; account: string }) {
|
|
+ apiBase = detail.apiBase;
|
|
localStorage.setItem('apiBase', apiBase);
|
|
- void loadDashboard();
|
|
- }
|
|
-
|
|
- async function loadDashboard() {
|
|
- connectionStatus = '连接中';
|
|
- try {
|
|
- dashboard = await createApi(apiBase).getProjectDashboard(1);
|
|
- connectionStatus = '已连接';
|
|
- } catch {
|
|
- dashboard = emptyDashboard;
|
|
- connectionStatus = '无法连接服务器';
|
|
- }
|
|
- }
|
|
-
|
|
- function handleConfirm(selected: Suggestion[]) {
|
|
- selectedSuggestionCount = selected.length;
|
|
+ currentUser = { account: detail.account };
|
|
}
|
|
</script>
|
|
|
|
-<main class="shell">
|
|
- <aside class="sidebar">
|
|
- <h1>项目工作台</h1>
|
|
- <ServerLogin {apiBase} on:serverChange={handleServerChange} />
|
|
- <p class="connection-status" aria-live="polite">{connectionStatus}</p>
|
|
- </aside>
|
|
-
|
|
- <section class="workspace">
|
|
- <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>
|
|
+{#if currentUser}
|
|
+ <main class="workbench-placeholder" aria-label="Project workbench">
|
|
+ <h1>SenlinAI Workbench</h1>
|
|
+ <p>Signed in as {currentUser.account}</p>
|
|
+ </main>
|
|
+{:else}
|
|
+ <ServerLogin {apiBase} onLogin={handleLogin} />
|
|
+{/if}
|
|
diff --git a/apps/web/src/features/auth/ServerLogin.svelte b/apps/web/src/features/auth/ServerLogin.svelte
|
|
index 8e80261..8e16cb9 100644
|
|
--- a/apps/web/src/features/auth/ServerLogin.svelte
|
|
+++ b/apps/web/src/features/auth/ServerLogin.svelte
|
|
@@ -1,28 +1,63 @@
|
|
<script lang="ts">
|
|
- import { createEventDispatcher } from 'svelte';
|
|
-
|
|
export let apiBase: string;
|
|
+ export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
|
|
|
|
- const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>();
|
|
let server = apiBase;
|
|
-
|
|
- function saveServer() {
|
|
- const normalized = normalizeServer(server);
|
|
- server = normalized;
|
|
- dispatch('serverChange', { apiBase: normalized });
|
|
- }
|
|
+ let account = '';
|
|
+ let password = '';
|
|
+ let error = '';
|
|
|
|
function normalizeServer(value: string) {
|
|
const trimmed = value.trim();
|
|
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
return 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>
|
|
|
|
-<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}>
|
|
- <label for="server-address">服务器 IP 或域名</label>
|
|
- <input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
|
- <button type="submit">保存服务器</button>
|
|
-</form>
|
|
+<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>
|
|
+
|
|
+ <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>
|
|
diff --git a/apps/web/src/features/auth/ServerLogin.test.ts b/apps/web/src/features/auth/ServerLogin.test.ts
|
|
new file mode 100644
|
|
index 0000000..7298b4b
|
|
--- /dev/null
|
|
+++ b/apps/web/src/features/auth/ServerLogin.test.ts
|
|
@@ -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();
|
|
+ });
|
|
+});
|