refactor(web): transform Settings and Assets overlays into full-page workspace views
This commit is contained in:
1 parent
f2ddaecdfa
commit
c0868dc5cb
5 files changed
+434
-360
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user