refactor(web): transform Settings and Assets overlays into full-page workspace views

This commit is contained in:
tianma committed 2026-06-28 00:47:30 +08:00
1 parent f2ddaecdfa
commit c0868dc5cb
5 files changed
+434 -360

No files matched your search

+115
View File
@@ -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 (
<div className="flex h-full min-h-0 flex-col bg-white">
<header className="flex h-[calc(4rem+env(safe-area-inset-top))] shrink-0 items-end justify-between border-b border-slate-200 px-6 pb-3 pt-[env(safe-area-inset-top)] lg:h-16 lg:items-center lg:pb-0 lg:pt-0">
<div className="flex items-center gap-3">
<Button
size="icon"
variant="ghost"
title="返回编辑器"
aria-label="返回编辑器"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-lg hover:bg-slate-100"
>
<ChevronLeft className="h-5 w-5 text-slate-500" />
</Button>
<div className="min-w-0">
<h1 className="flex items-center gap-2 text-base font-bold text-slate-900 leading-tight">
<Archive className="h-4.5 w-4.5 text-emerald-700" />
附件管理
</h1>
<p className="mt-0.5 flex flex-wrap items-center gap-2 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
<span className="inline-flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{formatBytes(summary.totalBytes)}
</span>
<span>•</span>
<span>{summary.totalCount} 文件</span>
<span>•</span>
<span>{summary.imageCount} 图片</span>
</p>
</div>
</div>
</header>
<div className="flex-1 overflow-y-auto p-6 bg-slate-50/30">
<div className="mx-auto max-w-4xl">
{resourcesQuery.isLoading ? (
<div className="flex items-center justify-center py-24 text-sm text-slate-400">正在加载附件列表...</div>
) : resources.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 bg-white px-6 py-24 text-center">
<Archive className="h-10 w-10 text-slate-350 mb-3 stroke-[1.5]" />
<p className="text-sm font-semibold text-slate-500">暂无附件</p>
<p className="mt-1 text-xs text-slate-400">插入到笔记中的图片或文件会显示在这里</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{resources.map((resource) => (
<a
key={resource.id}
className="group relative flex min-h-16 items-center gap-3.5 rounded-xl border border-slate-200/80 bg-white p-3.5 text-left transition-all duration-200 hover:border-[#627f58]/35 hover:shadow-[0_8px_20px_-4px_rgba(98,127,88,0.06)]"
href={resource.url}
target="_blank"
rel="noreferrer"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-slate-100 bg-slate-50/50 text-[#627f58] transition-colors group-hover:bg-[#f3f7f1]/60 group-hover:border-[#627f58]/20">
{resource.kind === "image" ? <ImageIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-slate-800 leading-snug group-hover:text-[#526d49] transition-colors">
{resource.filename || resource.id}
</span>
<span className="mt-1.5 block truncate text-[11px] font-medium text-slate-400">
{formatBytes(resource.byteSize)} · {resource.mimeType?.split("/")[1] || resource.kind} ·{" "}
{formatDateTime(resource.createdAt)}
</span>
<span className="mt-1 block truncate text-[11px] text-slate-500">
来源笔记:{resource.memoDeleted
? "已删除笔记"
: resource.memoTitle || resource.memoExcerpt || resource.memoId}
</span>
</span>
<ExternalLink className="h-4 w-4 shrink-0 text-slate-350 opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
</a>
))}
</div>
)}
</div>
</div>
</div>
);
};
+251
View File
@@ -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<Set<string>>(() => new Set(DEFAULT_TOKEN_SCOPES));
const [createdToken, setCreatedToken] = useState<{ token: string; apiToken: ApiToken } | null>(null);
const [tokenRevokeConfirmation, setTokenRevokeConfirmation] = useState<ApiToken | null>(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<HTMLFormElement>) => {
event.preventDefault();
const scopes = Array.from(selectedScopes);
if (!name.trim() || scopes.length === 0) {
return;
}
createMutation.mutate({ name: name.trim(), scopes });
};
return (
<div className="flex h-full min-h-0 flex-col bg-white">
<header className="flex h-[calc(4rem+env(safe-area-inset-top))] shrink-0 items-end justify-between border-b border-slate-200 px-6 pb-3 pt-[env(safe-area-inset-top)] lg:h-16 lg:items-center lg:pb-0 lg:pt-0">
<div className="flex items-center gap-3">
<Button
size="icon"
variant="ghost"
title="返回编辑器"
aria-label="返回编辑器"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-lg hover:bg-slate-100"
>
<ChevronLeft className="h-5 w-5 text-slate-500" />
</Button>
<div className="min-w-0">
<h1 className="flex items-center gap-2 text-base font-bold text-slate-900 leading-tight">
<KeyRound className="h-4.5 w-4.5 text-emerald-700" />
API & MCP 设置
</h1>
<p className="mt-0.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
管理用于本地 MCP 客户端或第三方 API 访问的身份凭证
</p>
</div>
</div>
</header>
<div className="flex-1 overflow-y-auto p-6 bg-slate-50/30">
<div className="mx-auto max-w-3xl space-y-6">
{createdToken && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50/70 p-4 shadow-sm animate-fade-in">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-emerald-900">
<ShieldCheck className="h-5 w-5 text-emerald-700" />
API Token 已成功生成
</div>
<div className="flex gap-2">
<input
className="h-10 min-w-0 flex-1 rounded-lg border border-emerald-200 bg-white px-3.5 font-mono text-xs text-slate-900 outline-none focus:ring-2 focus:ring-emerald-500/20"
readOnly
value={createdToken.token}
/>
<Button
size="md"
variant="solid"
className="bg-emerald-600 hover:bg-emerald-700 text-white"
type="button"
onClick={() => void navigator.clipboard?.writeText(createdToken.token)}
>
复制 Token
</Button>
</div>
<p className="mt-2 text-xs font-medium text-emerald-800">
⚠️ 安全警告:为了您的账户安全,明文 Token 仅在此处展示一次,关闭后将无法再次找回!
</p>
</div>
)}
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="text-sm font-bold text-slate-800 mb-4 flex items-center gap-2">
<Plus className="h-4 w-4 text-emerald-700" />
新建 API Token
</h2>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-3 sm:flex-row">
<input
className="h-10 min-w-0 flex-1 rounded-lg border border-slate-200 bg-slate-50/50 px-3.5 text-sm outline-none transition duration-150 focus:border-[#627f58] focus:bg-white focus:ring-4 focus:ring-[#627f58]/10"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Token 用途描述,例如: Cline Agent"
/>
<Button
size="md"
variant="solid"
className="h-10 bg-[#627f58] hover:bg-[#526d49] text-white"
type="submit"
disabled={createMutation.isPending}
>
<KeyRound className="h-4 w-4 mr-1.5" />
生成 Token
</Button>
</div>
<div className="space-y-2">
<span className="block text-xs font-semibold text-slate-500">选择 Scope 权限范围:</span>
<div className="grid gap-2 sm:grid-cols-2">
{availableScopes.map((scope) => (
<label
key={scope}
className={cn(
"flex min-h-10 items-center gap-3 rounded-lg border px-3 py-1 cursor-pointer transition-all duration-150",
selectedScopes.has(scope)
? "border-[#627f58]/30 bg-[#f3f7f1]/50 text-[#526d49]"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50/50"
)}
>
<input
type="checkbox"
checked={selectedScopes.has(scope)}
onChange={() => toggleScope(scope)}
className="h-4 w-4 shrink-0 rounded border-emerald-300 text-[#627f58] focus:ring-[#627f58]"
/>
<span className="min-w-0 truncate font-mono text-[11px] font-semibold">{scope}</span>
</label>
))}
</div>
</div>
</form>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 className="text-sm font-bold text-slate-800 mb-4">活跃的 Token 列表</h2>
<div className="space-y-3">
{tokensQuery.isLoading ? (
<div className="py-12 text-center text-sm text-slate-400">正在加载 Token 列表...</div>
) : tokens.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 px-4 py-12 text-center text-sm text-slate-400">
暂无活跃的 API Token
</div>
) : (
tokens.map((token) => (
<div
key={token.id}
className={cn(
"flex min-h-16 items-center gap-4 rounded-xl border p-4 transition-all duration-200",
token.isRevoked ? "border-slate-100 bg-slate-50/50 opacity-60" : "border-slate-200 bg-white hover:border-slate-300"
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-slate-800 leading-tight">{token.name}</span>
<span className="mt-2 block truncate font-mono text-[10px] font-semibold text-slate-500 bg-slate-50 px-2 py-1 rounded w-fit border border-slate-100">
{token.scopes.join(", ") || "no scopes"}
</span>
<span className="mt-2 block text-[10px] font-medium text-slate-400">
{token.lastUsedAt ? `上次调用时间:${formatDateTime(token.lastUsedAt)}` : "从未被调用"}
</span>
</span>
<Button
size="sm"
variant="danger"
className="shrink-0"
disabled={token.isRevoked || revokeMutation.isPending}
onClick={() => setTokenRevokeConfirmation(token)}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
撤销
</Button>
</div>
))
)}
</div>
</div>
</div>
</div>
{tokenRevokeConfirmation && (
<AppConfirmDialog
title={`确定要撤销 Token「${tokenRevokeConfirmation.name}」吗?`}
description="撤销操作不可逆。一旦撤销,使用此 Token 进行 API 或 MCP 调用的一切客户端将立即失效并被拒绝访问。"
confirmLabel="确认撤销"
isWorking={revokeMutation.isPending}
tone="danger"
onCancel={() => setTokenRevokeConfirmation(null)}
onConfirm={() => {
revokeMutation.mutate(tokenRevokeConfirmation.id, {
onSuccess: () => setTokenRevokeConfirmation(null),
});
}}
/>
)}
</div>
);
};
+68 -57
View File
@@ -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<AppNoticeDialogState | null>(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<MobileBottomNavItem>("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 = ({
<div className="flex h-[100dvh] overflow-hidden bg-[#f6faf7] text-slate-950">
<div className="min-w-0 flex-1">
<main
className="grid h-[100dvh] min-h-0 grid-cols-[minmax(0,1fr)] lg:grid-cols-[260px_var(--memo-list-width)_minmax(0,1fr)]"
className={cn(
"grid h-[100dvh] min-h-0 grid-cols-[minmax(0,1fr)]",
rightView === "editor"
? "lg:grid-cols-[260px_var(--memo-list-width)_minmax(0,1fr)]"
: "lg:grid-cols-[260px_1fr]"
)}
style={{ "--memo-list-width": `${memoListWidth}px` } as CSSProperties}
>
<aside
@@ -1401,6 +1405,7 @@ export const WorkspaceApp = ({
setMemoView("notebook");
setSelectedNotebookId(notebookId);
clearMemoSelection();
setRightView("editor");
setActivePane("memos");
}}
onCreateMemo={handleCreateMemo}
@@ -1415,6 +1420,7 @@ export const WorkspaceApp = ({
}
setSelectedNotebookId(null);
clearMemoSelection();
setRightView("editor");
setActivePane("memos");
}}
onLogout={onLogout}
@@ -1441,8 +1447,8 @@ export const WorkspaceApp = ({
<section
className={cn(
"relative min-w-0 overflow-hidden border-r border-slate-200 bg-[#f8faf8] lg:block lg:bg-white/75 lg:backdrop-blur-lg",
activePane === "memos" ? "block" : "hidden"
"relative min-w-0 overflow-hidden border-r border-slate-200 bg-[#f8faf8]",
activePane === "memos" && rightView === "editor" ? "block lg:block lg:bg-white/75 lg:backdrop-blur-lg" : "hidden lg:hidden"
)}
>
<MemoListPane
@@ -1488,6 +1494,7 @@ export const WorkspaceApp = ({
setActivePane("memos");
}}
onOpenMemo={(memoId) => {
setRightView("editor");
setSelectedMemoId(memoId);
setActivePane("editor");
}}
@@ -1543,46 +1550,50 @@ export const WorkspaceApp = ({
</section>
<section className={cn("min-h-0 min-w-0 bg-white lg:block", activePane === "editor" ? "block" : "hidden")}>
<EditorPane
memo={selectedMemo}
isTrashView={memoView === "trash"}
notebooks={notebooks}
isLoading={memoQuery.isLoading}
imageCompressionEnabled={imageCompressionEnabled}
hasNextMemo={Boolean(nextMemoId)}
hasPreviousMemo={Boolean(previousMemoId)}
onBackToList={() => setActivePane("memos")}
onOpenNextMemo={() => {
if (nextMemoId) {
setSelectedMemoId(nextMemoId);
}
}}
onOpenPreviousMemo={() => {
if (previousMemoId) {
setSelectedMemoId(previousMemoId);
}
}}
onSaved={async (memo) => {
queryClient.setQueryData(["memo", memo.id], { memo });
await queryClient.invalidateQueries({ queryKey: ["memos"] });
}}
onDeleted={async (memoId) => {
deleteMemoMutation.mutate({ memoId, permanent: false });
}}
onPermanentDeleted={async (memoId) => {
setMemoDeleteConfirmation({ kind: "single", memoIds: [memoId], permanent: true });
}}
onRestored={async (memoId) => {
await restoreMemoMutation.mutateAsync(memoId);
}}
/>
{rightView === "settings" ? (
<SettingsPane onClose={handleCloseSettings} />
) : rightView === "assets" ? (
<AssetsPane onClose={handleCloseAssets} />
) : (
<EditorPane
memo={selectedMemo}
isTrashView={memoView === "trash"}
notebooks={notebooks}
isLoading={memoQuery.isLoading}
imageCompressionEnabled={imageCompressionEnabled}
hasNextMemo={Boolean(nextMemoId)}
hasPreviousMemo={Boolean(previousMemoId)}
onBackToList={() => setActivePane("memos")}
onOpenNextMemo={() => {
if (nextMemoId) {
setSelectedMemoId(nextMemoId);
}
}}
onOpenPreviousMemo={() => {
if (previousMemoId) {
setSelectedMemoId(previousMemoId);
}
}}
onSaved={async (memo) => {
queryClient.setQueryData(["memo", memo.id], { memo });
await queryClient.invalidateQueries({ queryKey: ["memos"] });
}}
onDeleted={async (memoId) => {
deleteMemoMutation.mutate({ memoId, permanent: false });
}}
onPermanentDeleted={async (memoId) => {
setMemoDeleteConfirmation({ kind: "single", memoIds: [memoId], permanent: true });
}}
onRestored={async (memoId) => {
await restoreMemoMutation.mutateAsync(memoId);
}}
/>
)}
</section>
</main>
</div>
{assetsOpen && <AssetsDialog onClose={handleCloseAssets} />}
{tagsOpen && <TagsDialog onClose={() => setTagsOpen(false)} />}
{settingsOpen && <SettingsDialog onClose={handleCloseSettings} />}
{templatesOpen && (
<TemplatesDialog
canCreateMemo={canCreateMemo}
@@ -1,96 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Archive, HardDrive, ImageIcon, File as FileIcon, ExternalLink } 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 { 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]}`;
};
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 (
<Dialog open={true} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-[760px] p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<DialogHeader className="flex flex-row items-start justify-between gap-3 border-b border-slate-200 px-5 py-4 text-left">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold text-slate-950">
<Archive className="h-4 w-4 text-emerald-700" />
附件
</DialogTitle>
<DialogDescription className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<HardDrive className="h-3.5 w-3.5" />
{formatBytes(summary.totalBytes)}
</span>
<span>{summary.totalCount} files</span>
<span>{summary.imageCount} images</span>
</DialogDescription>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto p-5">
{resourcesQuery.isLoading ? (
<div className="px-2 py-8 text-center text-sm text-slate-500">加载中</div>
) : resources.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
暂无附件
</div>
) : (
<div className="space-y-2">
{resources.map((resource) => (
<a
key={resource.id}
className="flex min-h-16 items-center gap-3 rounded-md border border-slate-200 bg-white px-3 py-2 text-left transition hover:border-slate-300 hover:bg-slate-50"
href={resource.url}
target="_blank"
rel="noreferrer"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-white text-emerald-700">
{resource.kind === "image" ? <ImageIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-950">
{resource.filename || resource.id}
</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{formatBytes(resource.byteSize)} · {resource.mimeType ?? resource.kind} ·{" "}
{formatDateTime(resource.createdAt)}
</span>
<span className="mt-1 block truncate text-xs text-slate-400">
{resource.memoDeleted
? "已删除笔记"
: resource.memoTitle || resource.memoExcerpt || resource.memoId}
</span>
</span>
<ExternalLink className="h-4 w-4 shrink-0 text-slate-400" />
</a>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -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<Set<string>>(() => new Set(DEFAULT_TOKEN_SCOPES));
const [createdToken, setCreatedToken] = useState<{ token: string; apiToken: ApiToken } | null>(null);
const [tokenRevokeConfirmation, setTokenRevokeConfirmation] = useState<ApiToken | null>(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<HTMLFormElement>) => {
event.preventDefault();
const scopes = Array.from(selectedScopes);
if (!name.trim() || scopes.length === 0) {
return;
}
createMutation.mutate({ name: name.trim(), scopes });
};
return (
<Dialog open={true} onOpenChange={(open) => { if (!open && !tokenRevokeConfirmation) onClose(); }}>
<DialogContent className="max-w-[820px] p-0 overflow-hidden border border-slate-200 bg-white shadow-lg rounded-lg">
<DialogHeader className="flex flex-row items-start justify-between gap-3 border-b border-slate-200 px-5 py-4 text-left">
<div className="min-w-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold text-slate-950">
<KeyRound className="h-4 w-4 text-emerald-700" />
设置
</DialogTitle>
<DialogDescription className="mt-1 truncate text-xs text-slate-500">
API Token / MCP
</DialogDescription>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto p-5">
{createdToken && (
<div className="mb-4 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-emerald-950">
<ShieldCheck className="h-4 w-4" />
Token 已生成
</div>
<div className="flex gap-2">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-emerald-200 bg-white px-3 font-mono text-xs text-slate-900 outline-none"
readOnly
value={createdToken.token}
/>
<Button
size="sm"
variant="solid"
type="button"
onClick={() => void navigator.clipboard?.writeText(createdToken.token)}
>
复制
</Button>
</div>
<div className="mt-2 text-xs text-emerald-800">明文 Token 只显示这一次。</div>
</div>
)}
<form className="mb-5 rounded-md border border-slate-200 bg-slate-50 p-3" onSubmit={handleSubmit}>
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-slate-200 bg-white px-3 text-sm outline-none focus:border-emerald-300"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Token 名称"
/>
<Button size="md" variant="solid" type="submit" disabled={createMutation.isPending}>
<KeyRound className="h-4 w-4" />
生成 Token
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{availableScopes.map((scope) => (
<label
key={scope}
className="flex min-h-9 items-center gap-2 rounded-md border border-slate-200 bg-white px-2 text-sm text-slate-700"
>
<input
type="checkbox"
checked={selectedScopes.has(scope)}
onChange={() => toggleScope(scope)}
className="h-4 w-4 shrink-0 rounded border-emerald-300 text-emerald-600"
/>
<span className="min-w-0 truncate font-mono text-xs">{scope}</span>
</label>
))}
</div>
</form>
<div className="space-y-2">
{tokensQuery.isLoading ? (
<div className="px-2 py-8 text-center text-sm text-slate-500">加载中</div>
) : tokens.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500">
暂无 API Token
</div>
) : (
tokens.map((token) => (
<div
key={token.id}
className={cn(
"flex min-h-16 items-center gap-3 rounded-md border px-3 py-2",
token.isRevoked ? "border-slate-200 bg-slate-50 opacity-70" : "border-slate-200 bg-white"
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-950">{token.name}</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{token.scopes.join(", ") || "no scopes"}
</span>
<span className="mt-1 block text-xs text-slate-400">
{token.lastUsedAt ? `Last used ${formatDateTime(token.lastUsedAt)}` : "Never used"}
</span>
</span>
<Button
size="sm"
variant="danger"
disabled={token.isRevoked || revokeMutation.isPending}
onClick={() => setTokenRevokeConfirmation(token)}
>
撤销
</Button>
</div>
))
)}
</div>
</div>
</DialogContent>
{tokenRevokeConfirmation && (
<AppConfirmDialog
title={`撤销 Token「${tokenRevokeConfirmation.name}」`}
description="撤销后,正在使用这个 Token 的 MCP 或 Agent 客户端将无法继续访问。"
confirmLabel="撤销"
isWorking={revokeMutation.isPending}
tone="danger"
onCancel={() => setTokenRevokeConfirmation(null)}
onConfirm={() => {
revokeMutation.mutate(tokenRevokeConfirmation.id, {
onSuccess: () => setTokenRevokeConfirmation(null),
});
}}
/>
)}
</Dialog>
);
};