Merge branch 'dhk-1' into 'master'
笔记本页面 See merge request knowledge-base-bj/deerflow-server!39
This commit is contained in:
commit
0fb69a38cd
10 files changed
+689
-11
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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<SearchSpace> {
|
||||
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<SearchSpace[]> {
|
||||
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[]) : [];
|
||||
}
|
||||
@@ -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<NoteAuth> {
|
||||
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<NoteAuth>;
|
||||
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<NoteAuth> {
|
||||
const existing = getStoredNoteAuth();
|
||||
if (existing?.access_token) {
|
||||
return existing;
|
||||
}
|
||||
return loginNoteApi();
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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<NoteAuth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -21,7 +47,6 @@ export default function LightAppNewNotePage() {
|
||||
)}
|
||||
>
|
||||
<div className="w-full max-w-[720px] -translate-y-12 space-y-8">
|
||||
{/* 固定标题:新建笔记 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<NotebookPen
|
||||
className={cn("h-5 w-5 shrink-0", isDark ? "text-slate-400" : "text-black/70")}
|
||||
@@ -36,12 +61,18 @@ export default function LightAppNewNotePage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 标题输入框 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
value={titleInput}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={isBusy || !!authError}
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-70",
|
||||
isDark
|
||||
? "bg-blue-500 text-white hover:bg-blue-400"
|
||||
: "bg-sky-200 text-slate-700 hover:bg-sky-300",
|
||||
)}
|
||||
aria-label="进入笔记"
|
||||
aria-label="创建笔记"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
{creating || authLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 说明文字 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-11 items-center gap-2.5 rounded-xl px-4",
|
||||
@@ -88,6 +124,7 @@ export default function LightAppNewNotePage() {
|
||||
)}
|
||||
>
|
||||
新建笔记开始使用
|
||||
{authError ? `(${authError})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-8 shrink-0 items-center justify-center rounded-sm shadow-sm",
|
||||
isDark ? "bg-violet-400 text-slate-950" : "bg-[#4d3f74] text-white",
|
||||
)}
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LightAppNoteListPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const navigate = useNavigate();
|
||||
const { loading: authLoading, error: authError } = useNoteAuth();
|
||||
const [notes, setNotes] = useState<SearchSpace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/strategy/light-app/new-note")}
|
||||
className={cn(
|
||||
createButtonClass,
|
||||
"flex h-[132px] flex-col items-center justify-center rounded-2xl border border-dashed",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mb-3 flex h-9 w-9 items-center justify-center rounded-full transition-colors",
|
||||
isDark
|
||||
? "bg-slate-800 text-sky-300 group-hover:bg-slate-700"
|
||||
: "bg-sky-50 text-sky-500 group-hover:bg-sky-100",
|
||||
)}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-medium">新建笔记本</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/page/strategy/light-app/new-note")}
|
||||
className={cn(
|
||||
createButtonClass,
|
||||
"flex w-full items-center gap-4 rounded-2xl border border-dashed px-5 py-4",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
isDark
|
||||
? "bg-slate-800 text-sky-300 group-hover:bg-slate-700"
|
||||
: "bg-sky-50 text-sky-500 group-hover:bg-sky-100",
|
||||
)}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-medium">新建笔记本</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNoteCard = (note: SearchSpace, index: number) => (
|
||||
<article
|
||||
key={note.id}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-2xl p-4 shadow-sm ring-1 ring-black/5 transition-all hover:-translate-y-0.5 hover:shadow-lg",
|
||||
isDark
|
||||
? "h-[132px] bg-slate-900 text-slate-100 ring-white/10"
|
||||
: cn("h-[132px]", cardThemes[index % cardThemes.length]),
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute right-3 top-3 rounded-full p-1.5 opacity-80 transition-colors",
|
||||
isDark ? "bg-slate-800/80 text-slate-300" : "bg-white/70 text-slate-500",
|
||||
)}
|
||||
aria-label="更多"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<NoteIcon isDark={isDark} />
|
||||
|
||||
<h2 className="max-w-[130px] truncate text-xl font-semibold tracking-tight">
|
||||
{note.name}
|
||||
</h2>
|
||||
<p className={cn("mt-1 text-xs", isDark ? "text-slate-400" : "text-slate-600")}>
|
||||
{formatCreatedAt(note.created_at)} · {getSourceCount(note)} 个来源
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
const renderNoteRow = (note: SearchSpace) => (
|
||||
<article
|
||||
key={note.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-4 rounded-2xl px-5 py-4 shadow-sm ring-1 transition-all hover:-translate-y-0.5 hover:shadow-md",
|
||||
isDark
|
||||
? "bg-slate-900 text-slate-100 ring-white/10"
|
||||
: "bg-white ring-black/5",
|
||||
)}
|
||||
>
|
||||
<NoteIcon isDark={isDark} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-lg font-semibold tracking-tight">{note.name}</h2>
|
||||
<p className={cn("mt-1 text-sm", isDark ? "text-slate-400" : "text-slate-500")}>
|
||||
{formatCreatedAt(note.created_at)} · {getSourceCount(note)} 个来源
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"shrink-0 rounded-full p-2 opacity-80 transition-colors",
|
||||
isDark ? "text-slate-400 hover:bg-slate-800" : "text-slate-500 hover:bg-slate-100",
|
||||
)}
|
||||
aria-label="更多"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full overflow-y-auto px-10 py-10",
|
||||
isDark ? "bg-slate-950" : "bg-[#fdfcfc]",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1120px] space-y-7">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1
|
||||
className={cn(
|
||||
"text-[32px] font-semibold tracking-tight",
|
||||
isDark ? "text-slate-100" : "text-slate-950",
|
||||
)}
|
||||
>
|
||||
我的笔记本
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex rounded-xl p-1",
|
||||
isDark ? "bg-slate-900" : "bg-white shadow-sm ring-1 ring-black/5",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={cn(
|
||||
"rounded-lg p-2 transition-colors",
|
||||
viewMode === "grid"
|
||||
? isDark
|
||||
? "bg-slate-800 text-slate-100"
|
||||
: "bg-slate-100 text-slate-900"
|
||||
: isDark
|
||||
? "text-slate-500 hover:text-slate-300"
|
||||
: "text-slate-400 hover:text-slate-600",
|
||||
)}
|
||||
aria-label="网格视图"
|
||||
aria-pressed={viewMode === "grid"}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={cn(
|
||||
"rounded-lg p-2 transition-colors",
|
||||
viewMode === "list"
|
||||
? isDark
|
||||
? "bg-slate-800 text-slate-100"
|
||||
: "bg-slate-100 text-slate-900"
|
||||
: isDark
|
||||
? "text-slate-500 hover:text-slate-300"
|
||||
: "text-slate-400 hover:text-slate-600",
|
||||
)}
|
||||
aria-label="列表视图"
|
||||
aria-pressed={viewMode === "list"}
|
||||
>
|
||||
<LayoutList className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadNotes()}
|
||||
disabled={isBusy || !!authError}
|
||||
className={cn(
|
||||
"rounded-xl p-2 shadow-sm ring-1 ring-black/5 transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
isDark
|
||||
? "bg-slate-900 text-slate-300 hover:bg-slate-800"
|
||||
: "bg-white text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
aria-label="刷新"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border px-4 py-3 text-sm",
|
||||
isDark
|
||||
? "border-red-500/30 bg-red-500/10 text-red-300"
|
||||
: "border-red-200 bg-red-50 text-red-600",
|
||||
)}
|
||||
>
|
||||
{authError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !authError && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border px-4 py-3 text-sm",
|
||||
isDark
|
||||
? "border-red-500/30 bg-red-500/10 text-red-300"
|
||||
: "border-red-200 bg-red-50 text-red-600",
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy && !authError && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2
|
||||
className={cn("h-6 w-6 animate-spin", isDark ? "text-slate-400" : "text-black/40")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBusy && !authError && !error && viewMode === "grid" && (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4">
|
||||
{renderCreateButton("grid")}
|
||||
{notes.map((note, index) => renderNoteCard(note, index))}
|
||||
{notes.length === 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[132px] items-center justify-center rounded-2xl border text-sm",
|
||||
isDark
|
||||
? "border-slate-800 bg-slate-900/50 text-slate-500"
|
||||
: "border-slate-100 bg-white/70 text-slate-400",
|
||||
)}
|
||||
>
|
||||
暂无笔记
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBusy && !authError && !error && viewMode === "list" && (
|
||||
<div className="space-y-3">
|
||||
{renderCreateButton("list")}
|
||||
{notes.map((note) => renderNoteRow(note))}
|
||||
{notes.length === 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border px-6 py-10 text-center text-sm",
|
||||
isDark
|
||||
? "border-slate-800 bg-slate-900/50 text-slate-500"
|
||||
: "border-slate-100 bg-white/70 text-slate-400",
|
||||
)}
|
||||
>
|
||||
暂无笔记
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
{/* 轻应用 */}
|
||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||
|
||||
{/* 对话 */}
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
|
||||
Reference in new issue
Block a user