feat: add svelte web workspace shell
This commit is contained in:
30
apps/web/src/app/App.svelte
Normal file
30
apps/web/src/app/App.svelte
Normal 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
10
apps/web/src/app/tabs.ts
Normal 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];
|
||||
}
|
||||
BIN
apps/web/src/assets/hero.png
Normal file
BIN
apps/web/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
apps/web/src/assets/vite.svg
Normal file
1
apps/web/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
28
apps/web/src/features/auth/ServerLogin.svelte
Normal file
28
apps/web/src/features/auth/ServerLogin.svelte
Normal 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>
|
||||
21
apps/web/src/features/projects/ProjectDashboard.svelte
Normal file
21
apps/web/src/features/projects/ProjectDashboard.svelte
Normal 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>
|
||||
25
apps/web/src/features/projects/ProjectDashboard.test.ts
Normal file
25
apps/web/src/features/projects/ProjectDashboard.test.ts
Normal 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
111
apps/web/src/index.css
Normal 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
23
apps/web/src/lib/api.ts
Normal 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
10
apps/web/src/main.ts
Normal 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
2
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user