diff --git a/apps/web/src/components/AssetsPane.tsx b/apps/web/src/components/AssetsPane.tsx new file mode 100644 index 00000000..280eb048 --- /dev/null +++ b/apps/web/src/components/AssetsPane.tsx @@ -0,0 +1,115 @@ +import { useQuery } from "@tanstack/react-query"; +import { Archive, HardDrive, ImageIcon, File as FileIcon, ExternalLink, ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { formatDateTime } from "@/lib/utils"; + +export const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / 1024 ** exponent; + + return `${exponent === 0 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[exponent]}`; +}; + +interface AssetsPaneProps { + onClose: () => void; +} + +export const AssetsPane = ({ onClose }: AssetsPaneProps) => { + const resourcesQuery = useQuery({ + queryKey: ["resources"], + queryFn: () => api.listResources(), + }); + const resources = resourcesQuery.data?.resources ?? []; + const summary = resourcesQuery.data?.summary ?? { + totalCount: 0, + totalBytes: 0, + imageCount: 0, + attachmentCount: 0, + }; + + return ( +
+
+
+ +
+

+ + 附件管理 +

+

+ + + {formatBytes(summary.totalBytes)} + + • + {summary.totalCount} 文件 + • + {summary.imageCount} 图片 +

+
+
+
+ +
+
+ {resourcesQuery.isLoading ? ( +
正在加载附件列表...
+ ) : resources.length === 0 ? ( +
+ +

暂无附件

+

插入到笔记中的图片或文件会显示在这里

+
+ ) : ( +
+ {resources.map((resource) => ( + + + {resource.kind === "image" ? : } + + + + {resource.filename || resource.id} + + + {formatBytes(resource.byteSize)} · {resource.mimeType?.split("/")[1] || resource.kind} ·{" "} + {formatDateTime(resource.createdAt)} + + + 来源笔记:{resource.memoDeleted + ? "已删除笔记" + : resource.memoTitle || resource.memoExcerpt || resource.memoId} + + + + + ))} +
+ )} +
+
+
+ ); +}; diff --git a/apps/web/src/components/SettingsPane.tsx b/apps/web/src/components/SettingsPane.tsx new file mode 100644 index 00000000..114c7088 --- /dev/null +++ b/apps/web/src/components/SettingsPane.tsx @@ -0,0 +1,251 @@ +import { useState, type FormEvent } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, ShieldCheck, Trash2, ChevronLeft, Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import { cn, formatDateTime } from "@/lib/utils"; +import { AppConfirmDialog } from "./dialogs/ConfirmDialogs"; +import type { ApiToken } from "@edgeever/shared"; + +const DEFAULT_TOKEN_SCOPES = ["read:notebooks", "read:memos", "read:tags"]; + +interface SettingsPaneProps { + onClose: () => void; +} + +export const SettingsPane = ({ onClose }: SettingsPaneProps) => { + const queryClient = useQueryClient(); + const [name, setName] = useState("MCP Agent"); + const [selectedScopes, setSelectedScopes] = useState>(() => new Set(DEFAULT_TOKEN_SCOPES)); + const [createdToken, setCreatedToken] = useState<{ token: string; apiToken: ApiToken } | null>(null); + const [tokenRevokeConfirmation, setTokenRevokeConfirmation] = useState(null); + + const tokensQuery = useQuery({ + queryKey: ["api-tokens"], + queryFn: () => api.listApiTokens(), + }); + + const availableScopes = tokensQuery.data?.availableScopes ?? [ + "read:notebooks", + "write:notebooks", + "read:memos", + "write:memos", + "read:resources", + "write:resources", + "read:tags", + "write:tags", + ]; + + const createMutation = useMutation({ + mutationFn: api.createApiToken, + onSuccess: async (data) => { + setCreatedToken(data); + setName(""); + setSelectedScopes(new Set(DEFAULT_TOKEN_SCOPES)); + await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: api.revokeApiToken, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); + }, + }); + + const tokens = tokensQuery.data?.apiTokens ?? []; + + const toggleScope = (scope: string) => { + setSelectedScopes((current) => { + const next = new Set(current); + if (next.has(scope)) { + next.delete(scope); + } else { + next.add(scope); + } + return next; + }); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const scopes = Array.from(selectedScopes); + + if (!name.trim() || scopes.length === 0) { + return; + } + + createMutation.mutate({ name: name.trim(), scopes }); + }; + + return ( +
+
+
+ +
+

+ + API & MCP 设置 +

+

+ 管理用于本地 MCP 客户端或第三方 API 访问的身份凭证 +

+
+
+
+ +
+
+ {createdToken && ( +
+
+ + API Token 已成功生成 +
+
+ + +
+

+ ⚠️ 安全警告:为了您的账户安全,明文 Token 仅在此处展示一次,关闭后将无法再次找回! +

+
+ )} + +
+

+ + 新建 API Token +

+
+
+ setName(event.target.value)} + placeholder="Token 用途描述,例如: Cline Agent" + /> + +
+ +
+ 选择 Scope 权限范围: +
+ {availableScopes.map((scope) => ( + + ))} +
+
+
+
+ +
+

活跃的 Token 列表

+
+ {tokensQuery.isLoading ? ( +
正在加载 Token 列表...
+ ) : tokens.length === 0 ? ( +
+ 暂无活跃的 API Token +
+ ) : ( + tokens.map((token) => ( +
+ + {token.name} + + {token.scopes.join(", ") || "no scopes"} + + + {token.lastUsedAt ? `上次调用时间:${formatDateTime(token.lastUsedAt)}` : "从未被调用"} + + + +
+ )) + )} +
+
+
+
+ + {tokenRevokeConfirmation && ( + setTokenRevokeConfirmation(null)} + onConfirm={() => { + revokeMutation.mutate(tokenRevokeConfirmation.id, { + onSuccess: () => setTokenRevokeConfirmation(null), + }); + }} + /> + )} +
+ ); +}; diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 608b8e2a..6fe11966 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -16,9 +16,9 @@ import { Button } from "@/components/ui/button"; import { NotebookPane } from "./NotebookPane"; import { MemoListPane } from "./MemoListPane"; import { EditorPane } from "./EditorPane"; -import { AssetsDialog } from "./dialogs/AssetsDialog"; +import { AssetsPane } from "./AssetsPane"; import { TagsDialog } from "./dialogs/TagsDialog"; -import { SettingsDialog } from "./dialogs/SettingsDialog"; +import { SettingsPane } from "./SettingsPane"; import { TemplatesDialog } from "./dialogs/TemplatesDialog"; import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from "./dialogs/ConfirmDialogs"; import { api } from "@/lib/api"; @@ -453,9 +453,8 @@ export const WorkspaceApp = ({ const [appNoticeDialog, setAppNoticeDialog] = useState(null); const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference); - const [assetsOpen, setAssetsOpen] = useState(false); + const [rightView, setRightView] = useState<"editor" | "settings" | "assets">("editor"); const [tagsOpen, setTagsOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); const [templatesOpen, setTemplatesOpen] = useState(false); const [mobileNotebookPickerOpen, setMobileNotebookPickerOpen] = useState(false); const [mobileBottomNavActive, setMobileBottomNavActive] = useState("home"); @@ -515,9 +514,8 @@ export const WorkspaceApp = ({ mobileNotebookPickerOpen || mobileSearchActive || templatesOpen || - settingsOpen || + rightView !== "editor" || tagsOpen || - assetsOpen || memoSelectionModeActive || activePane === "editor" || activePane === "notebooks" @@ -685,6 +683,7 @@ export const WorkspaceApp = ({ setMemoView("notebook"); await queryClient.invalidateQueries({ queryKey: ["memos"] }); queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); + setRightView("editor"); setSelectedMemoId(data.memo.id); setActivePane("editor"); }, @@ -696,6 +695,7 @@ export const WorkspaceApp = ({ clearMemoSelection(); await queryClient.invalidateQueries({ queryKey: ["memos"] }); queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); + setRightView("editor"); setSelectedMemoId(data.memo.id); setActivePane("editor"); }, @@ -792,6 +792,7 @@ export const WorkspaceApp = ({ await queryClient.invalidateQueries({ queryKey: ["memos"] }); queryClient.setQueryData(["memo", data.memo.id], { memo: data.memo }); setSelectedNotebookId(data.memo.notebookId); + setRightView("editor"); setSelectedMemoId(data.memo.id); setActivePane("editor"); }, @@ -1083,7 +1084,8 @@ export const WorkspaceApp = ({ const handleOpenAssets = () => { clearHiddenMobileSearch(); - setAssetsOpen(true); + setRightView("assets"); + setActivePane("editor"); }; const handleOpenTags = () => { @@ -1099,12 +1101,12 @@ export const WorkspaceApp = ({ const handleOpenSettings = () => { clearHiddenMobileSearch(); - setMobileBottomNavActive("settings"); - setSettingsOpen(true); + setRightView("settings"); + setActivePane("editor"); }; const handleCloseAssets = () => { - setAssetsOpen(false); + setRightView("editor"); setMobileBottomNavActive("home"); }; @@ -1114,7 +1116,7 @@ export const WorkspaceApp = ({ }; const handleCloseSettings = () => { - setSettingsOpen(false); + setRightView("editor"); setMobileBottomNavActive("home"); }; @@ -1160,7 +1162,7 @@ export const WorkspaceApp = ({ return true; } - if (settingsOpen) { + if (rightView === "settings") { handleCloseSettings(); return true; } @@ -1170,7 +1172,7 @@ export const WorkspaceApp = ({ return true; } - if (assetsOpen) { + if (rightView === "assets") { handleCloseAssets(); return true; } @@ -1189,7 +1191,7 @@ export const WorkspaceApp = ({ }, [ activePane, appNoticeDialog, - assetsOpen, + rightView, clearMemoSelection, createNotebookMutation.isPending, deleteMemoMutation.isPending, @@ -1205,7 +1207,6 @@ export const WorkspaceApp = ({ mobileSearchActive, notebookDeleteConfirmation, notebookNameDialog, - settingsOpen, tagsOpen, templatesOpen, updateNotebookMutation.isPending, @@ -1249,12 +1250,11 @@ export const WorkspaceApp = ({ const transientLayerOpen = Boolean( appNoticeDialog || - assetsOpen || + rightView !== "editor" || memoDeleteConfirmation || mobileNotebookPickerOpen || notebookDeleteConfirmation || notebookNameDialog || - settingsOpen || tagsOpen || templatesOpen ); @@ -1290,7 +1290,7 @@ export const WorkspaceApp = ({ window.addEventListener("keydown", handleWorkspaceShortcut); return () => window.removeEventListener("keydown", handleWorkspaceShortcut); }, [ - assetsOpen, + rightView, appNoticeDialog, canCreateMemo, clearMemoSelection, @@ -1303,7 +1303,6 @@ export const WorkspaceApp = ({ mobileNotebookPickerOpen, notebookDeleteConfirmation, notebookNameDialog, - settingsOpen, tagsOpen, templatesOpen, ]); @@ -1381,7 +1380,12 @@ export const WorkspaceApp = ({
- {assetsOpen && } {tagsOpen && setTagsOpen(false)} />} - {settingsOpen && } {templatesOpen && ( { - if (!Number.isFinite(bytes) || bytes <= 0) { - return "0 B"; - } - - const units = ["B", "KB", "MB", "GB", "TB"]; - const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - const value = bytes / 1024 ** exponent; - - return `${exponent === 0 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[exponent]}`; -}; - -export const AssetsDialog = ({ onClose }: { onClose: () => void }) => { - const resourcesQuery = useQuery({ - queryKey: ["resources"], - queryFn: () => api.listResources(), - }); - const resources = resourcesQuery.data?.resources ?? []; - const summary = resourcesQuery.data?.summary ?? { - totalCount: 0, - totalBytes: 0, - imageCount: 0, - attachmentCount: 0, - }; - - return ( - { if (!open) onClose(); }}> - - -
- - - 附件 - - - - - {formatBytes(summary.totalBytes)} - - {summary.totalCount} files - {summary.imageCount} images - -
-
- - -
-
- ); -}; diff --git a/apps/web/src/components/dialogs/SettingsDialog.tsx b/apps/web/src/components/dialogs/SettingsDialog.tsx deleted file mode 100644 index d0de895e..00000000 --- a/apps/web/src/components/dialogs/SettingsDialog.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { useState, type FormEvent } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { KeyRound, ShieldCheck, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { api } from "@/lib/api"; -import { cn, formatDateTime } from "@/lib/utils"; -import { AppConfirmDialog } from "./ConfirmDialogs"; -import type { ApiToken } from "@edgeever/shared"; - -const DEFAULT_TOKEN_SCOPES = ["read:notebooks", "read:memos", "read:tags"]; - -export const SettingsDialog = ({ onClose }: { onClose: () => void }) => { - const queryClient = useQueryClient(); - const [name, setName] = useState("MCP Agent"); - const [selectedScopes, setSelectedScopes] = useState>(() => new Set(DEFAULT_TOKEN_SCOPES)); - const [createdToken, setCreatedToken] = useState<{ token: string; apiToken: ApiToken } | null>(null); - const [tokenRevokeConfirmation, setTokenRevokeConfirmation] = useState(null); - - const tokensQuery = useQuery({ - queryKey: ["api-tokens"], - queryFn: () => api.listApiTokens(), - }); - - const availableScopes = tokensQuery.data?.availableScopes ?? [ - "read:notebooks", - "write:notebooks", - "read:memos", - "write:memos", - "read:resources", - "write:resources", - "read:tags", - "write:tags", - ]; - - const createMutation = useMutation({ - mutationFn: api.createApiToken, - onSuccess: async (data) => { - setCreatedToken(data); - setName(""); - setSelectedScopes(new Set(DEFAULT_TOKEN_SCOPES)); - await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); - }, - }); - - const revokeMutation = useMutation({ - mutationFn: api.revokeApiToken, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); - }, - }); - - const tokens = tokensQuery.data?.apiTokens ?? []; - - const toggleScope = (scope: string) => { - setSelectedScopes((current) => { - const next = new Set(current); - if (next.has(scope)) { - next.delete(scope); - } else { - next.add(scope); - } - return next; - }); - }; - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - const scopes = Array.from(selectedScopes); - - if (!name.trim() || scopes.length === 0) { - return; - } - - createMutation.mutate({ name: name.trim(), scopes }); - }; - - return ( - { if (!open && !tokenRevokeConfirmation) onClose(); }}> - - -
- - - 设置 - - - API Token / MCP - -
-
- -
- {createdToken && ( -
-
- - Token 已生成 -
-
- - -
-
明文 Token 只显示这一次。
-
- )} - -
-
- setName(event.target.value)} - placeholder="Token 名称" - /> - -
-
- {availableScopes.map((scope) => ( - - ))} -
-
- -
- {tokensQuery.isLoading ? ( -
加载中
- ) : tokens.length === 0 ? ( -
- 暂无 API Token -
- ) : ( - tokens.map((token) => ( -
- - {token.name} - - {token.scopes.join(", ") || "no scopes"} - - - {token.lastUsedAt ? `Last used ${formatDateTime(token.lastUsedAt)}` : "Never used"} - - - -
- )) - )} -
-
-
- - {tokenRevokeConfirmation && ( - setTokenRevokeConfirmation(null)} - onConfirm={() => { - revokeMutation.mutate(tokenRevokeConfirmation.id, { - onSuccess: () => setTokenRevokeConfirmation(null), - }); - }} - /> - )} -
- ); -};