feat: add svelte web workspace shell

This commit is contained in:
2026-07-18 16:40:09 +08:00
parent 833d9f094f
commit a758996799
26 changed files with 3090 additions and 7 deletions

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import ProjectDashboard from '../features/projects/ProjectDashboard.svelte';
import ServerLogin from '../features/auth/ServerLogin.svelte';
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
apiBase = event.detail.apiBase;
localStorage.setItem('apiBase', apiBase);
}
</script>
<main class="shell">
<aside class="sidebar">
<h1>项目工作台</h1>
<ServerLogin {apiBase} on:serverChange={handleServerChange} />
</aside>
<section class="workspace">
<ProjectDashboard
summary={{
project_id: 1,
pending_inbox_count: 0,
open_task_count: 0,
recent_note_count: 0,
recent_session_count: 0,
}}
/>
</section>
</main>

10
apps/web/src/app/tabs.ts Normal file
View File

@@ -0,0 +1,10 @@
export type WorkspaceTab = {
key: string;
title: string;
route: string;
};
export function openTab(existing: WorkspaceTab[], next: WorkspaceTab): WorkspaceTab[] {
if (existing.some((tab) => tab.key === next.key)) return existing;
return [...existing, next];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let apiBase: string;
const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>();
let server = apiBase;
function saveServer() {
const normalized = normalizeServer(server);
server = normalized;
dispatch('serverChange', { apiBase: normalized });
}
function normalizeServer(value: string) {
const trimmed = value.trim();
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
return `http://${trimmed}`;
}
</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>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { ProjectDashboardSummary } from '../../lib/api';
export let summary: ProjectDashboardSummary;
$: metrics = [
['待处理 Inbox', summary.pending_inbox_count],
['当前任务', summary.open_task_count],
['笔记资料', summary.recent_note_count],
['AI 会话', summary.recent_session_count],
] as const;
</script>
<section aria-label="项目总览" class="dashboard-grid">
{#each metrics as [label, value]}
<article class="dashboard-metric">
<span>{label}</span>
<strong>{value}</strong>
</article>
{/each}
</section>

View File

@@ -0,0 +1,25 @@
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import ProjectDashboard from './ProjectDashboard.svelte';
describe('ProjectDashboard', () => {
it('shows project counters', () => {
render(ProjectDashboard, {
props: {
summary: {
project_id: 1,
pending_inbox_count: 2,
open_task_count: 5,
recent_note_count: 3,
recent_session_count: 4,
},
},
});
expect(screen.getByText('待处理 Inbox')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('当前任务')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
});

111
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,111 @@
:root {
color: #202124;
background: #f6f7f9;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
textarea,
select {
font: inherit;
}
.shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
border-right: 1px solid #d9dde3;
background: #ffffff;
padding: 24px;
}
.sidebar h1 {
margin: 0 0 24px;
font-size: 24px;
}
.workspace {
padding: 24px;
}
.server-login {
display: grid;
gap: 8px;
}
.server-login label {
font-size: 13px;
color: #4b5563;
}
.server-login input {
width: 100%;
border: 1px solid #c7ccd4;
border-radius: 6px;
padding: 9px 10px;
}
.server-login button {
border: 1px solid #1f2937;
border-radius: 6px;
background: #1f2937;
color: white;
padding: 9px 10px;
cursor: pointer;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 12px;
}
.dashboard-metric {
border: 1px solid #d9dde3;
border-radius: 8px;
background: white;
padding: 16px;
}
.dashboard-metric span {
display: block;
color: #5f6673;
font-size: 14px;
}
.dashboard-metric strong {
display: block;
margin-top: 8px;
font-size: 28px;
}
@media (max-width: 760px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid #d9dde3;
}
.dashboard-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

23
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,23 @@
export type Project = { id: number; name: string; description: string };
export type ProjectDashboardSummary = {
project_id: number;
pending_inbox_count: number;
open_task_count: number;
recent_note_count: number;
recent_session_count: number;
};
export function createApi(apiBase: string) {
async function request<T>(path: string): Promise<T> {
const response = await fetch(`${apiBase}${path}`);
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
return response.json() as Promise<T>;
}
return {
getProjects: () => request<Project[]>('/projects'),
getProjectDashboard: (projectID: number) =>
request<ProjectDashboardSummary>(`/projects/${projectID}/dashboard`),
};
}

10
apps/web/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import './index.css';
import App from './app/App.svelte';
const target = document.getElementById('app');
if (!target) {
throw new Error('Missing #app mount target');
}
new App({ target });

2
apps/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />