From 125e8b271bf7d08182da1a141eb51567c95df8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 21 May 2026 19:40:54 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/.env.example | 3 + frontend-web/src/components/page-sidebar.tsx | 25 +- frontend-web/src/core/config/index.ts | 10 + frontend-web/src/core/note/api.ts | 103 +++++ frontend-web/src/core/note/auth.ts | 94 +++++ frontend-web/src/env.ts | 2 + frontend-web/src/hooks/useNoteAuth.ts | 39 ++ .../src/pages/LightAppNewNotePage.tsx | 51 ++- .../src/pages/LightAppNoteListPage.tsx | 371 ++++++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 2 + 10 files changed, 689 insertions(+), 11 deletions(-) create mode 100644 frontend-web/src/core/note/api.ts create mode 100644 frontend-web/src/core/note/auth.ts create mode 100644 frontend-web/src/hooks/useNoteAuth.ts create mode 100644 frontend-web/src/pages/LightAppNoteListPage.tsx diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 06579a79..0968e8ff 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -4,3 +4,6 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api + +# 轻应用「新建笔记」系列接口(与 DeerFlow Gateway 分离) +VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 306dc883..945d2878 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -21,11 +21,13 @@ import { History, Layout, Library, + List, ListChecks, ListTodo, MessageSquareCode, MessageSquarePlus, Network, + NotebookPen, Plus, Settings, Sparkles, @@ -81,10 +83,23 @@ const menuItems: MenuItem[] = [ icon: Grid3X3, children: [ { - id: "light-app-new-note", - label: "新建笔记", - icon: Plus, - path: "/page/strategy/light-app/new-note", + id: "light-app-note-management", + label: "笔记管理", + icon: NotebookPen, + children: [ + { + id: "light-app-new-note", + label: "新建笔记", + icon: Plus, + path: "/page/strategy/light-app/new-note", + }, + { + id: "light-app-note-list", + label: "笔记列表", + icon: List, + path: "/page/strategy/light-app/notes", + }, + ], }, { id: "light-app-square", label: "应用广场", icon: Layout }, { id: "light-app-records", label: "运行记录", icon: History }, @@ -208,6 +223,8 @@ const menuItems: MenuItem[] = [ const defaultExpandedIds = new Set([ "qa-center", + "light-app-center", + "light-app-note-management", "task-management", "xk-center-task", ]); diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 928a1339..1eb5cc28 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -68,3 +68,13 @@ export function getLangGraphBaseURL(isMock?: boolean) { return "http://localhost:2026/api/langgraph"; } } + +/** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */ +export function getNoteApiBaseURL(): string { + if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) { + return new URL(env.NEXT_PUBLIC_NOTE_API_BASE_URL, getBaseOrigin()) + .toString() + .replace(/\/+$/, ""); + } + return ""; +} diff --git a/frontend-web/src/core/note/api.ts b/frontend-web/src/core/note/api.ts new file mode 100644 index 00000000..71a1c2cc --- /dev/null +++ b/frontend-web/src/core/note/api.ts @@ -0,0 +1,103 @@ +import { getNoteApiBaseURL } from "@/core/config"; + +import { + ensureNoteAuth, + getNoteAuthorizationHeader, + getStoredNoteAuth, +} from "./auth"; + +export type { NoteAuth } from "./auth"; + +export interface SearchSpace { + created_at: string; + id: number; + name: string; + description: string; + user_id: string; + citations_enabled: boolean; + qna_custom_instructions: string; + summary: string | null; + avatar_url: string | null; + sources_limit: number; + sources_used: number; + is_shared: boolean; + is_owner: boolean; + owner_email: string | null; + member_count?: number; + source_count?: number; + membership_role?: string; +} + +export interface ListSearchSpacesQuery { + owned_only?: boolean; + limit?: number; + order_by?: string; +} + +export interface CreateSearchSpaceInput { + name: string; + description?: string; + citations_enabled?: boolean; +} + +export async function noteApiFetch( + path: string, + init: RequestInit = {}, +): Promise { + const auth = getStoredNoteAuth() ?? (await ensureNoteAuth()); + + const headers = new Headers(init.headers); + headers.set("Authorization", getNoteAuthorizationHeader(auth)); + + return fetch(`${getNoteApiBaseURL()}${path}`, { + ...init, + headers, + }); +} + +export async function createSearchSpace( + input: CreateSearchSpaceInput, +): Promise { + await ensureNoteAuth(); + + const response = await noteApiFetch("/api/v1/searchspaces", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: input.name, + description: input.description ?? "", + citations_enabled: input.citations_enabled ?? true, + }), + }); + + if (!response.ok) { + throw new Error(`创建笔记失败: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as SearchSpace; +} + +export async function listSearchSpaces( + query: ListSearchSpacesQuery = {}, +): Promise { + await ensureNoteAuth(); + + const params = new URLSearchParams({ + owned_only: String(query.owned_only ?? true), + limit: String(query.limit ?? 1000), + order_by: query.order_by ?? "created_at", + }); + + const response = await noteApiFetch(`/api/v1/searchspaces?${params.toString()}`, { + method: "GET", + }); + + if (!response.ok) { + throw new Error(`获取笔记列表失败: ${response.status} ${response.statusText}`); + } + + const data: unknown = await response.json(); + return Array.isArray(data) ? (data as SearchSpace[]) : []; +} diff --git a/frontend-web/src/core/note/auth.ts b/frontend-web/src/core/note/auth.ts new file mode 100644 index 00000000..fca3c650 --- /dev/null +++ b/frontend-web/src/core/note/auth.ts @@ -0,0 +1,94 @@ +import { getNoteApiBaseURL } from "@/core/config"; + +const NOTE_AUTH_STORAGE_KEY = "deerflow.note.auth"; + +const NOTE_LOGIN_USERNAME = "dhk@qq.com"; +const NOTE_LOGIN_PASSWORD = "1qaz@WSX"; + +export interface NoteAuth { + access_token: string; + token_type: string; +} + +function isBrowser(): boolean { + return typeof window !== "undefined"; +} + +function assertNoteApiBaseURL(): string { + const baseURL = getNoteApiBaseURL(); + if (!baseURL) { + throw new Error("未配置 VITE_NOTE_API_BASE_URL"); + } + return baseURL; +} + +export function getStoredNoteAuth(): NoteAuth | null { + if (!isBrowser()) { + return null; + } + const raw = window.localStorage.getItem(NOTE_AUTH_STORAGE_KEY); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as NoteAuth; + } catch { + return null; + } +} + +export function setStoredNoteAuth(auth: NoteAuth): void { + if (!isBrowser()) { + return; + } + window.localStorage.setItem(NOTE_AUTH_STORAGE_KEY, JSON.stringify(auth)); +} + +export function clearStoredNoteAuth(): void { + if (!isBrowser()) { + return; + } + window.localStorage.removeItem(NOTE_AUTH_STORAGE_KEY); +} + +export function getNoteAuthorizationHeader(auth: NoteAuth): string { + const token = auth.access_token.trim(); + const tokenType = auth.token_type?.trim() || "Bearer"; + return `${tokenType} ${token}`; +} + +export async function loginNoteApi(): Promise { + const formData = new FormData(); + formData.append("username", NOTE_LOGIN_USERNAME); + formData.append("password", NOTE_LOGIN_PASSWORD); + formData.append("grant_type", "password"); + + const response = await fetch(`${assertNoteApiBaseURL()}/auth/jwt/login`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`笔记登录失败: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as Partial; + if (!data.access_token) { + throw new Error("笔记登录失败: 未返回 access_token"); + } + + const auth: NoteAuth = { + access_token: data.access_token, + token_type: data.token_type?.trim() || "bearer", + }; + setStoredNoteAuth(auth); + return auth; +} + +export async function ensureNoteAuth(): Promise { + const existing = getStoredNoteAuth(); + if (existing?.access_token) { + return existing; + } + return loginNoteApi(); +} diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index e387c0f2..5e1d7c01 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -5,6 +5,7 @@ function readEnv( | "VITE_BACKEND_BASE_URL" | "VITE_LANGGRAPH_BASE_URL" | "VITE_CANVAS_LANGGRAPH_BASE_URL" + | "VITE_NOTE_API_BASE_URL" | "VITE_STATIC_WEBSITE_ONLY" | "VITE_GITHUB_OAUTH_TOKEN", ): EnvValue { @@ -18,6 +19,7 @@ export const env = { // Canvas page uses its own LangGraph base URL so it can be independently // routed (e.g. a separate canvas-agent server). Falls back to the main URL. NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"), + NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"), GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), }; diff --git a/frontend-web/src/hooks/useNoteAuth.ts b/frontend-web/src/hooks/useNoteAuth.ts new file mode 100644 index 00000000..529181d3 --- /dev/null +++ b/frontend-web/src/hooks/useNoteAuth.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useState } from "react"; + +import { loginNoteApi, type NoteAuth } from "@/core/note/auth"; + +export function useNoteAuth() { + const [auth, setAuth] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refreshAuth = useCallback(async () => { + setLoading(true); + setError(null); + try { + const nextAuth = await loginNoteApi(); + setAuth(nextAuth); + return nextAuth; + } catch (err) { + const message = + err instanceof Error ? err.message : "笔记登录失败,请稍后重试"; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refreshAuth().catch(() => { + // 错误已在 refreshAuth 中写入 state + }); + }, [refreshAuth]); + + return { + auth, + loading, + error, + refreshAuth, + }; +} diff --git a/frontend-web/src/pages/LightAppNewNotePage.tsx b/frontend-web/src/pages/LightAppNewNotePage.tsx index 22facec4..79c545c5 100644 --- a/frontend-web/src/pages/LightAppNewNotePage.tsx +++ b/frontend-web/src/pages/LightAppNewNotePage.tsx @@ -1,17 +1,43 @@ "use client"; import { useMemo, useState } from "react"; -import { ArrowRight, NotebookPen, Sparkles } from "lucide-react"; +import { ArrowRight, Loader2, NotebookPen, Sparkles } from "lucide-react"; +import { toast } from "sonner"; import { useTheme } from "@/contexts/ThemeContext"; +import { createSearchSpace } from "@/core/note/api"; +import { useNoteAuth } from "@/hooks/useNoteAuth"; import { cn } from "@/lib/utils"; export default function LightAppNewNotePage() { const { theme } = useTheme(); const isDark = theme === "dark"; + const { loading: authLoading, error: authError } = useNoteAuth(); const [titleInput, setTitleInput] = useState(""); + const [creating, setCreating] = useState(false); const hasTitle = useMemo(() => titleInput.trim().length > 0, [titleInput]); + const isBusy = authLoading || creating; + + const handleCreate = async () => { + const name = titleInput.trim(); + if (!name || isBusy || authError) { + return; + } + + setCreating(true); + try { + const searchSpace = await createSearchSpace({ name }); + toast.success(`笔记「${searchSpace.name}」创建成功`); + setTitleInput(""); + } catch (error) { + const message = + error instanceof Error ? error.message : "创建笔记失败,请稍后重试"; + toast.error(message); + } finally { + setCreating(false); + } + }; return (
- {/* 固定标题:新建笔记 */}
- {/* 标题输入框 */}
setTitleInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleCreate(); + } + }} placeholder="新建笔记" + disabled={isBusy} className={cn( "min-w-0 flex-1 bg-transparent text-[42px] leading-tight font-normal tracking-tight outline-none", hasTitle @@ -56,20 +87,25 @@ export default function LightAppNewNotePage() { {hasTitle && ( )}
- {/* 说明文字 */}
新建笔记开始使用 + {authError ? `(${authError})` : ""}

diff --git a/frontend-web/src/pages/LightAppNoteListPage.tsx b/frontend-web/src/pages/LightAppNoteListPage.tsx new file mode 100644 index 00000000..17ca89ca --- /dev/null +++ b/frontend-web/src/pages/LightAppNoteListPage.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + BookOpen, + Grid3X3, + LayoutList, + Loader2, + MoreHorizontal, + Plus, + RefreshCw, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { useTheme } from "@/contexts/ThemeContext"; +import { listSearchSpaces, type SearchSpace } from "@/core/note/api"; +import { useNoteAuth } from "@/hooks/useNoteAuth"; +import { cn } from "@/lib/utils"; + +type ViewMode = "grid" | "list"; + +function formatCreatedAt(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); +} + +function getSourceCount(note: SearchSpace): number { + return note.source_count ?? note.sources_used ?? 0; +} + +const cardThemes = [ + "bg-[#e4f2f6]", + "bg-[#f0eaf3]", + "bg-[#edf4e6]", + "bg-[#f5eadf]", + "bg-[#e9edf8]", +]; + +function NoteIcon({ isDark }: { isDark: boolean }) { + return ( +
+ +
+ ); +} + +export default function LightAppNoteListPage() { + const { theme } = useTheme(); + const isDark = theme === "dark"; + const navigate = useNavigate(); + const { loading: authLoading, error: authError } = useNoteAuth(); + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState("grid"); + + const loadNotes = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await listSearchSpaces({ + owned_only: true, + limit: 1000, + order_by: "created_at", + }); + setNotes(data); + } catch (err) { + const message = + err instanceof Error ? err.message : "获取笔记列表失败,请稍后重试"; + setError(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (authLoading || authError) { + return; + } + void loadNotes(); + }, [authError, authLoading, loadNotes]); + + const isBusy = authLoading || loading; + + const createButtonClass = cn( + "group text-center transition-all hover:-translate-y-0.5", + isDark + ? "border-slate-700 bg-slate-900/70 text-slate-300 hover:border-slate-500 hover:bg-slate-900" + : "border-slate-200 bg-white text-slate-600 shadow-sm hover:border-sky-200 hover:shadow-md", + ); + + const renderCreateButton = (layout: ViewMode) => { + if (layout === "grid") { + return ( + + ); + } + + return ( + + ); + }; + + const renderNoteCard = (note: SearchSpace, index: number) => ( +
+ + + + +

+ {note.name} +

+

+ {formatCreatedAt(note.created_at)} · {getSourceCount(note)} 个来源 +

+
+ ); + + const renderNoteRow = (note: SearchSpace) => ( +
+ + +
+

{note.name}

+

+ {formatCreatedAt(note.created_at)} · {getSourceCount(note)} 个来源 +

+
+ + +
+ ); + + return ( +
+
+
+

+ 我的笔记本 +

+ +
+
+ + +
+ +
+
+ + {authError && ( +
+ {authError} +
+ )} + + {error && !authError && ( +
+ {error} +
+ )} + + {isBusy && !authError && ( +
+ +
+ )} + + {!isBusy && !authError && !error && viewMode === "grid" && ( +
+ {renderCreateButton("grid")} + {notes.map((note, index) => renderNoteCard(note, index))} + {notes.length === 0 && ( +
+ 暂无笔记 +
+ )} +
+ )} + + {!isBusy && !authError && !error && viewMode === "list" && ( +
+ {renderCreateButton("list")} + {notes.map((note) => renderNoteRow(note))} + {notes.length === 0 && ( +
+ 暂无笔记 +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index aa1b8039..9fb4ffc3 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -25,6 +25,7 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminUserActivityPage from "./AdminUserActivityPage"; import TagsPage from "./TagsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; +import LightAppNoteListPage from "./LightAppNoteListPage"; export default function StrategyRoutes() { return ( @@ -39,6 +40,7 @@ export default function StrategyRoutes() { {/* 轻应用 */} } /> + } /> {/* 对话 */} } />