diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 7ca7d7f3..146e5557 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -16,7 +16,7 @@ import { AgentCard } from "./agent-card"; export function AgentGallery() { const { t } = useI18n(); const router = useRouter(); - const [activeTab, setActiveTab] = useState<"all" | "mine">("all"); + const [activeTab, setActiveTab] = useState<"all" | "mine">("mine"); const [search, setSearch] = useState(""); const [tagId, setTagId] = useState(null); // Name + tag filtering is resolved server-side via the /api/agents query. @@ -89,8 +89,8 @@ export function AgentGallery() { >
- 全部 - 自己的 + 我的 + 广场 0; - const items = notifications ?? []; - - async function handleClickItem(notification: NotificationItem) { - if (notification.read) return; - try { - await markRead.mutateAsync(notification.id); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - async function handleMarkAllRead() { - if (!hasUnread) return; - try { - await markAllRead.mutateAsync(); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - async function handleDelete(notification: NotificationItem) { - try { - await removeNotification.mutateAsync(notification.id); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } - } - - return ( - - - - {isSidebarOpen ? ( -
-
- - {hasUnread && ( - - {unreadCount > 99 ? "99+" : unreadCount} - - )} -
- {t.notifications.title} - {hasUnread && ( - - {t.notifications.unreadShort.replace("{count}", String(unreadCount))} - - )} -
- ) : ( -
-
- - {hasUnread && ( - - )} -
-
- )} -
-
- -
-
- - - {t.notifications.title} - - {isFetching && !isLoading && ( - - )} -
- -
- - {isLoading ? ( -
- - {t.common.loading} -
- ) : items.length === 0 ? ( -
- {t.notifications.empty} -
- ) : ( -
    - {items.map((notification) => ( -
  • void handleClickItem(notification)} - > -
    - {!notification.read && ( - - )} -
    -
    -

    - {notification.title || notification.type} -

    - {notification.created_at && ( - - {formatTimeAgo(notification.created_at)} - - )} -
    - {notification.body && ( -

    - {notification.body} -

    - )} -
    - -
    -
  • - ))} -
- )} -
-
-
- ); -} diff --git a/frontend-web/src/components/workspace/notification-dialog.tsx b/frontend-web/src/components/workspace/notification-dialog.tsx new file mode 100644 index 00000000..73d2c0db --- /dev/null +++ b/frontend-web/src/components/workspace/notification-dialog.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { + BellIcon, + CheckCheckIcon, + ChevronLeftIcon, + Loader2, + SettingsIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Switch } from "@/components/ui/switch"; +import { useI18n } from "@/core/i18n/hooks"; +import { useNotification } from "@/core/notification/hooks"; +import { + useDeleteNotification, + useMarkAllNotificationsRead, + useMarkNotificationRead, + useNotifications, + useUnreadCount, +} from "@/core/notifications"; +import type { NotificationItem } from "@/core/notifications"; +import { useLocalSettings } from "@/core/settings"; +import { formatTimeAgo } from "@/core/utils/datetime"; +import { cn } from "@/lib/utils"; + +const BELL_LIMIT = 20; + +type NotificationView = "list" | "settings"; + +export function NotificationDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + const [view, setView] = useState("list"); + + const { + data: notifications, + isLoading, + isFetching, + } = useNotifications({ limit: BELL_LIMIT }); + const { data: unreadCount = 0 } = useUnreadCount(); + const markRead = useMarkNotificationRead(); + const markAllRead = useMarkAllNotificationsRead(); + const removeNotification = useDeleteNotification(); + + const hasUnread = unreadCount > 0; + const items = notifications ?? []; + + function handleOpenChange(next: boolean) { + onOpenChange(next); + // 关闭弹窗时回到列表视图,下次打开默认看到通知列表。 + if (!next) setView("list"); + } + + async function handleClickItem(notification: NotificationItem) { + if (notification.read) return; + try { + await markRead.mutateAsync(notification.id); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function handleMarkAllRead() { + if (!hasUnread) return; + try { + await markAllRead.mutateAsync(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + async function handleDelete(notification: NotificationItem) { + try { + await removeNotification.mutateAsync(notification.id); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + return ( + + + {view === "list" ? ( + <> +
+
+ + + {t.notifications.title} + + {isFetching && !isLoading && ( + + )} +
+
+ + + + + +
+
+ +
+ {isLoading ? ( +
+ + {t.common.loading} +
+ ) : items.length === 0 ? ( +
+
+ +
+

+ {t.notifications.empty} +

+
+ ) : ( +
    + {items.map((notification) => ( +
  • void handleClickItem(notification)} + > +
    + +
    +
    +

    + {notification.title || notification.type} +

    + {notification.created_at && ( + + {formatTimeAgo(notification.created_at)} + + )} +
    + {notification.body && ( +

    + {notification.body} +

    + )} +
    + +
    +
  • + ))} +
+ )} +
+
+ + ) : ( + setView("list")} /> + )} +
+
+ ); +} + +function NotificationSettingsView({ onBack }: { onBack: () => void }) { + const { t } = useI18n(); + const { permission, isSupported, requestPermission, showNotification } = + useNotification(); + const [settings, setSettings] = useLocalSettings(); + + const handleRequestPermission = async () => { + await requestPermission(); + }; + + const handleTestNotification = () => { + showNotification(t.settings.notification.testTitle, { + body: t.settings.notification.testBody, + }); + }; + + const handleEnableNotification = (enabled: boolean) => { + setSettings("notification", { enabled }); + }; + + return ( + <> +
+
+ + + {t.notifications.settingsTitle} + +
+ + + +
+
+ {!isSupported ? ( +

+ {t.settings.notification.notSupported} +

+ ) : ( + <> +
+

+ {t.settings.notification.description} +

+ +
+ + {permission === "default" && ( + + )} + + {permission === "denied" && ( +

+ {t.settings.notification.deniedHint} +

+ )} + + {permission === "granted" && settings.notification.enabled && ( + + )} + + )} +
+ + ); +} diff --git a/frontend-web/src/components/workspace/settings/notification-settings-page.tsx b/frontend-web/src/components/workspace/settings/notification-settings-page.tsx deleted file mode 100644 index 82b0cb8c..00000000 --- a/frontend-web/src/components/workspace/settings/notification-settings-page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { BellIcon } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { useI18n } from "@/core/i18n/hooks"; -import { useNotification } from "@/core/notification/hooks"; -import { useLocalSettings } from "@/core/settings"; - -import { SettingsSection } from "./settings-section"; - -export function NotificationSettingsPage() { - const { t } = useI18n(); - const { permission, isSupported, requestPermission, showNotification } = - useNotification(); - - const [settings, setSettings] = useLocalSettings(); - - const handleRequestPermission = async () => { - await requestPermission(); - }; - - const handleTestNotification = () => { - showNotification(t.settings.notification.testTitle, { - body: t.settings.notification.testBody, - }); - }; - - const handleEnableNotification = async (enabled: boolean) => { - setSettings("notification", { - enabled, - }); - }; - - if (!isSupported) { - return ( - -

- {t.settings.notification.notSupported} -

-
- ); - } - - return ( - -
{t.settings.notification.description}
-
- -
-
- } - > -
- {permission === "default" && ( - - )} - - {permission === "denied" && ( -

- {t.settings.notification.deniedHint} -

- )} - - {permission === "granted" && settings.notification.enabled && ( -
- -
- )} -
- - ); -} diff --git a/frontend-web/src/components/workspace/settings/settings-dialog.tsx b/frontend-web/src/components/workspace/settings/settings-dialog.tsx index 1c306e9b..ec51a71c 100644 --- a/frontend-web/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend-web/src/components/workspace/settings/settings-dialog.tsx @@ -1,13 +1,6 @@ "use client"; -import { - BellIcon, - BrainIcon, - PaletteIcon, - SparklesIcon, - UserIcon, - WrenchIcon, -} from "lucide-react"; +import { BrainIcon, PaletteIcon, UserIcon, WrenchIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { @@ -21,8 +14,6 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page"; -import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page"; -import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page"; import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; @@ -32,8 +23,6 @@ type SettingsSection = | "appearance" | "memory" | "tools" - | "skills" - | "notification" | "about"; type SettingsDialogProps = React.ComponentProps & { @@ -61,18 +50,12 @@ export function SettingsDialog(props: SettingsDialogProps) { label: t.settings.sections.appearance, icon: PaletteIcon, }, - { - id: "notification", - label: t.settings.sections.notification, - icon: BellIcon, - }, { id: "memory", label: t.settings.sections.memory, icon: BrainIcon, }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, - { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon }, { id: "account", label: "个人信息", @@ -84,8 +67,6 @@ export function SettingsDialog(props: SettingsDialogProps) { t.settings.sections.appearance, t.settings.sections.memory, t.settings.sections.tools, - t.settings.sections.skills, - t.settings.sections.notification, t.settings.sections.about, ], ); @@ -135,8 +116,6 @@ export function SettingsDialog(props: SettingsDialogProps) { {activeSection === "appearance" && } {activeSection === "memory" && } {activeSection === "tools" && } - {activeSection === "notification" && } - {activeSection === "skills" && } {activeSection === "about" && } diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 0b2eb366..b5b5056a 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -58,6 +58,7 @@ import { useInstallSkillUpload, useSetSkillPinned, useSetSkillPublished, + useSkillContent, useSkills, useValidateSkillUpload, } from "@/core/skills/hooks"; @@ -83,7 +84,7 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) { function SkillSettingsList({ onClose }: { onClose?: () => void }) { const { t } = useI18n(); const router = useRouter(); - const [filter, setFilter] = useState("all"); + const [filter, setFilter] = useState("mine"); const [installOpen, setInstallOpen] = useState(false); const [staleSheetOpen, setStaleSheetOpen] = useState(false); const [archiveSheetOpen, setArchiveSheetOpen] = useState(false); @@ -96,9 +97,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { const currentUserId = storedAuth?.user_id ?? null; const isAdmin = storedAuth?.system_role === "admin"; - // Visible-to-me skills, classified into the three buckets the UI cares about. - // "All" = built-in + anyone-published-custom + mine (active state only). - // "Mine" = strictly my own custom skills (active state only). + // 把可见技能分类成 UI 关心的几个桶。 + // "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。 + // "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。 const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => { const all: Skill[] = []; const mine: Skill[] = []; @@ -110,8 +111,6 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { const isOwnedByMe = isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId; const isBuiltin = skill.builtin ?? !skill.owner_user_id; - const isPublishedByOthers = - isCustom && !isOwnedByMe && !isBuiltin && !!skill.published; if (state === "stale") { stale.push(skill); @@ -122,7 +121,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { continue; } // state === active - if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) { + if (isBuiltin || !!skill.published) { all.push(skill); } if (isOwnedByMe) { @@ -151,8 +150,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
- {t.settings.skills.tabAll} {t.settings.skills.tabMine} + {t.settings.skills.tabAll}