feat: add namespace governance dashboard

This commit is contained in:
yun-zhi-ztl 2026-03-16 12:06:56 +08:00 • committed by Xudong Sun
parent 33ed8fe17d
commit f92cdb31f2
10 changed files with 461 additions and 21 deletions

View file

@ -19,6 +19,8 @@ import type {
AuthMethod,
OAuthProvider,
User,
ManagedNamespace,
Namespace,
} from './types'
import { ApiError } from '@/shared/lib/api-error'
import i18n from '@/i18n/config'
@ -460,6 +462,51 @@ export const skillLifecycleApi = {
},
}
function normalizeNamespaceSlug(namespace: string): string {
return namespace.startsWith('@') ? namespace.slice(1) : namespace
}
export const namespaceApi = {
async listMine(): Promise<ManagedNamespace[]> {
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
},
async getDetail(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`)
},
async freeze(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/freeze`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async unfreeze(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async archive(slug: string, reason?: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/archive`, {
method: 'POST',
headers: await ensureCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}),
})
},
async restore(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/restore`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
}
export const tokenApi = {
async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> {
const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', {

View file

@ -82,6 +82,9 @@ export interface MergeConfirmRequest {
}
// Namespace types
export type NamespaceStatus = 'ACTIVE' | 'FROZEN' | 'ARCHIVED' | string
export type NamespaceRole = 'OWNER' | 'ADMIN' | 'MEMBER' | string
export interface Namespace {
id: number
slug: string
@ -89,15 +92,25 @@ export interface Namespace {
description?: string
type: 'GLOBAL' | 'TEAM'
avatarUrl?: string
status: string
status: NamespaceStatus
createdAt: string
updatedAt?: string
}
export interface ManagedNamespace extends Namespace {
createdBy?: string
currentUserRole?: NamespaceRole
immutable: boolean
canFreeze: boolean
canUnfreeze: boolean
canArchive: boolean
canRestore: boolean
}
export interface NamespaceMember {
id: number
userId: string
role: string
role: NamespaceRole
createdAt: string
}

View file

@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'
import type { Namespace } from '@/api/types'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { cn } from '@/shared/lib/utils'
interface NamespaceHeaderProps {
namespace: Namespace
@ -8,6 +9,24 @@ interface NamespaceHeaderProps {
export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
const { t } = useTranslation()
const statusLabel = namespace.status === 'FROZEN'
? t('namespaceStatus.frozen')
: namespace.status === 'ARCHIVED'
? t('namespaceStatus.archived')
: t('namespaceStatus.active')
const statusClassName = namespace.status === 'FROZEN'
? 'bg-amber-500/10 text-amber-500 border-amber-500/20'
: namespace.status === 'ARCHIVED'
? 'bg-slate-500/10 text-slate-500 border-slate-500/20'
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
const hint = namespace.type === 'GLOBAL'
? t('namespaceStatus.immutableHint')
: namespace.status === 'FROZEN'
? t('namespaceStatus.frozenHint')
: namespace.status === 'ARCHIVED'
? t('namespaceStatus.archivedHint')
: null
return (
<div className="flex items-start gap-4 p-6 border rounded-lg bg-card">
{namespace.avatarUrl && (
@ -18,9 +37,12 @@ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
/>
)}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold">{namespace.displayName}</h1>
<NamespaceBadge type={namespace.type} name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')} />
<span className={cn('inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium', statusClassName)}>
{statusLabel}
</span>
</div>
{namespace.description && (
<p className="text-muted-foreground">{namespace.description}</p>
@ -28,6 +50,11 @@ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
<div className="text-sm text-muted-foreground">
@{namespace.slug}
</div>
{hint ? (
<div className="rounded-lg border border-border/50 bg-secondary/40 px-3 py-2 text-sm text-muted-foreground">
{hint}
</div>
) : null}
</div>
</div>
)

View file

@ -20,13 +20,12 @@ function getAppBaseUrl(): string {
return `${window.location.protocol}//${window.location.host}`
}
export function InstallCommand({ namespace, slug, version }: InstallCommandProps) {
export function InstallCommand({ slug }: InstallCommandProps) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const baseUrl = useMemo(() => getAppBaseUrl(), [])
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
const command = useMemo(() => {
const installCmd = `clawhub install ${slug} `
// 如果是默认的 clawhub.ai 不需要环境变量,否则显示完整配置
@ -34,7 +33,7 @@ export function InstallCommand({ namespace, slug, version }: InstallCommandProps
return `CLAWHUB_SITE=${baseUrl} CLAWHUB_REGISTRY=${baseUrl} ${installCmd}`
}
return installCmd
}, [cleanNamespace, slug, version, baseUrl])
}, [baseUrl, slug])
const handleCopy = async () => {
try {

View file

@ -265,8 +265,38 @@
"create": "Create Namespace",
"typeGlobal": "Global",
"typeTeam": "Team",
"roleLabel": "Current role",
"roleUnknown": "Unknown",
"manageMembers": "Manage Members",
"reviewTasks": "Review Tasks",
"freeze": "Freeze",
"unfreeze": "Unfreeze",
"archive": "Archive",
"restore": "Restore",
"activeHint": "This namespace is fully active and can continue managing members, reviews, and skill publishing.",
"frozenHint": "This namespace is frozen. Members can still view it, but it is read-only and cannot publish or change membership.",
"archivedHint": "This namespace is archived. Public entry points are hidden, but you can still view and restore it from the dashboard.",
"immutableHint": "This is a built-in system namespace and always remains read-only.",
"freezeConfirmTitle": "Freeze namespace",
"freezeConfirmDescription": "\"{{name}}\" will become read-only after freezing. Continue?",
"unfreezeConfirmTitle": "Unfreeze namespace",
"unfreezeConfirmDescription": "\"{{name}}\" will regain normal management and publishing after unfreezing.",
"archiveConfirmTitle": "Archive namespace",
"archiveConfirmDescription": "\"{{name}}\" will be hidden from public entry points and only remain recoverable from the dashboard.",
"restoreConfirmTitle": "Restore namespace",
"restoreConfirmDescription": "\"{{name}}\" will return to an active operating state.",
"freezeSuccessTitle": "Namespace frozen",
"freezeSuccessDescription": "\"{{name}}\" is now read-only.",
"freezeErrorTitle": "Failed to freeze namespace",
"unfreezeSuccessTitle": "Namespace unfrozen",
"unfreezeSuccessDescription": "\"{{name}}\" is active again.",
"unfreezeErrorTitle": "Failed to unfreeze namespace",
"archiveSuccessTitle": "Namespace archived",
"archiveSuccessDescription": "\"{{name}}\" is now hidden from public entry points.",
"archiveErrorTitle": "Failed to archive namespace",
"restoreSuccessTitle": "Namespace restored",
"restoreSuccessDescription": "\"{{name}}\" can operate normally again.",
"restoreErrorTitle": "Failed to restore namespace",
"emptyTitle": "No namespaces yet",
"emptyDescription": "Create a namespace to organize your skills"
},
@ -546,7 +576,10 @@
"colActions": "Actions",
"remove": "Remove",
"empty": "No members",
"namespaceNotFound": "Namespace not found"
"namespaceNotFound": "Namespace not found",
"globalReadOnly": "This is a built-in system namespace. Membership can only be viewed and cannot be changed.",
"frozenReadOnly": "This namespace is frozen. You can still view members, but cannot add, remove, or change roles.",
"archivedReadOnly": "This namespace is archived. You can still view members, but cannot change membership until it is restored."
},
"upload": {
"dropHint": "Drop to upload...",
@ -564,7 +597,18 @@
"version": "Version {{version}}",
"tabPending": "Pending",
"tabApproved": "Approved",
"tabRejected": "Rejected"
"tabRejected": "Rejected",
"globalReadOnly": "This is a built-in system namespace. Review history is visible, but no new review actions are allowed.",
"frozenReadOnly": "This namespace is frozen. You can still view review history, but cannot continue processing review tasks.",
"archivedReadOnly": "This namespace is archived. You can still view review history, but cannot continue processing review tasks until it is restored."
},
"namespaceStatus": {
"active": "Active",
"frozen": "Frozen",
"archived": "Archived",
"frozenHint": "This namespace is currently read-only and cannot publish, review, or change members.",
"archivedHint": "This namespace is hidden from public entry points and only visible to dashboard members.",
"immutableHint": "This is a built-in system namespace and does not allow governance or membership changes."
},
"fileTree": {
"title": "Files ({{count}})"

View file

@ -265,8 +265,38 @@
"create": "创建命名空间",
"typeGlobal": "全局",
"typeTeam": "团队",
"roleLabel": "当前角色",
"roleUnknown": "未知",
"manageMembers": "管理成员",
"reviewTasks": "审核任务",
"freeze": "冻结",
"unfreeze": "解冻",
"archive": "归档",
"restore": "恢复",
"activeHint": "命名空间运行正常,可继续管理成员、处理审核和发布技能。",
"frozenHint": "命名空间已冻结。成员仍可查看,但当前处于只读状态,不能继续发布或变更成员。",
"archivedHint": "命名空间已归档。公开入口已隐藏,但你仍可在管理台查看并恢复。",
"immutableHint": "系统内置命名空间,始终保持只读,不允许任何治理操作。",
"freezeConfirmTitle": "确认冻结命名空间",
"freezeConfirmDescription": "冻结后“{{name}}”将进入只读状态,确定继续吗?",
"unfreezeConfirmTitle": "确认解冻命名空间",
"unfreezeConfirmDescription": "解冻后“{{name}}”会重新恢复正常管理和发布能力。",
"archiveConfirmTitle": "确认归档命名空间",
"archiveConfirmDescription": "归档后“{{name}}”会从公开入口隐藏,仅保留管理台恢复入口。",
"restoreConfirmTitle": "确认恢复命名空间",
"restoreConfirmDescription": "恢复后“{{name}}”会重新进入可运营状态。",
"freezeSuccessTitle": "命名空间已冻结",
"freezeSuccessDescription": "“{{name}}”已切换为只读状态。",
"freezeErrorTitle": "冻结命名空间失败",
"unfreezeSuccessTitle": "命名空间已解冻",
"unfreezeSuccessDescription": "“{{name}}”已恢复正常状态。",
"unfreezeErrorTitle": "解冻命名空间失败",
"archiveSuccessTitle": "命名空间已归档",
"archiveSuccessDescription": "“{{name}}”已从公开入口隐藏。",
"archiveErrorTitle": "归档命名空间失败",
"restoreSuccessTitle": "命名空间已恢复",
"restoreSuccessDescription": "“{{name}}”已重新开放治理能力。",
"restoreErrorTitle": "恢复命名空间失败",
"emptyTitle": "还没有命名空间",
"emptyDescription": "创建一个命名空间来组织你的技能"
},
@ -546,7 +576,10 @@
"colActions": "操作",
"remove": "移除",
"empty": "暂无成员",
"namespaceNotFound": "命名空间不存在"
"namespaceNotFound": "命名空间不存在",
"globalReadOnly": "这是系统内置命名空间,成员信息仅供查看,不能进行任何变更。",
"frozenReadOnly": "命名空间已冻结,成员列表仍可查看,但当前不能添加、移除或调整成员角色。",
"archivedReadOnly": "命名空间已归档,成员列表仍可查看,但在恢复前不能修改成员。"
},
"upload": {
"dropHint": "放开以上传文件...",
@ -564,7 +597,18 @@
"version": "版本 {{version}}",
"tabPending": "待审核",
"tabApproved": "已通过",
"tabRejected": "已拒绝"
"tabRejected": "已拒绝",
"globalReadOnly": "这是系统内置命名空间,仅可查看审核记录,不允许执行新的审核动作。",
"frozenReadOnly": "命名空间已冻结。你仍可查看审核记录,但当前不能继续处理审核任务。",
"archivedReadOnly": "命名空间已归档。你仍可查看审核记录,但在恢复前不能继续处理审核任务。"
},
"namespaceStatus": {
"active": "正常",
"frozen": "已冻结",
"archived": "已归档",
"frozenHint": "命名空间当前为只读状态,不能继续发布、审核或调整成员。",
"archivedHint": "命名空间已从公开入口隐藏,仅管理台成员可见并可恢复。",
"immutableHint": "系统内置命名空间,不允许任何治理或成员修改操作。"
},
"fileTree": {
"title": "文件列表 ({{count}})"

View file

@ -1,16 +1,30 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { EmptyState } from '@/shared/components/empty-state'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { useMyNamespaces } from '@/shared/hooks/use-skill-queries'
import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-skill-queries'
import { toast } from '@/shared/lib/toast'
type PendingNamespaceAction =
| { action: 'freeze'; slug: string; name: string }
| { action: 'unfreeze'; slug: string; name: string }
| { action: 'archive'; slug: string; name: string }
| { action: 'restore'; slug: string; name: string }
export function MyNamespacesPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const [pendingAction, setPendingAction] = useState<PendingNamespaceAction | null>(null)
const { data: namespaces, isLoading } = useMyNamespaces()
const freezeMutation = useFreezeNamespace()
const unfreezeMutation = useUnfreezeNamespace()
const archiveMutation = useArchiveNamespace()
const restoreMutation = useRestoreNamespace()
const handleNamespaceClick = (slug: string) => {
navigate({ to: `/space/${slug}` })
@ -26,6 +40,110 @@ export function MyNamespacesPage() {
navigate({ to: `/dashboard/namespaces/${slug}/reviews` })
}
const resolveStatusLabel = (status: string) => {
if (status === 'FROZEN') {
return t('namespaceStatus.frozen')
}
if (status === 'ARCHIVED') {
return t('namespaceStatus.archived')
}
return t('namespaceStatus.active')
}
const resolveStatusClassName = (status: string) => {
if (status === 'FROZEN') {
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}
if (status === 'ARCHIVED') {
return 'bg-slate-500/10 text-slate-500 border-slate-500/20'
}
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
}
const resolveHint = (status: string, type: string) => {
if (type === 'GLOBAL') {
return t('myNamespaces.immutableHint')
}
if (status === 'FROZEN') {
return t('myNamespaces.frozenHint')
}
if (status === 'ARCHIVED') {
return t('myNamespaces.archivedHint')
}
return t('myNamespaces.activeHint')
}
const resolveActionCopy = (action: PendingNamespaceAction['action'], name: string) => {
if (action === 'freeze') {
return {
title: t('myNamespaces.freezeConfirmTitle'),
description: t('myNamespaces.freezeConfirmDescription', { name }),
confirmText: t('myNamespaces.freeze'),
successTitle: t('myNamespaces.freezeSuccessTitle'),
successDescription: t('myNamespaces.freezeSuccessDescription', { name }),
errorTitle: t('myNamespaces.freezeErrorTitle'),
variant: 'default' as const,
}
}
if (action === 'unfreeze') {
return {
title: t('myNamespaces.unfreezeConfirmTitle'),
description: t('myNamespaces.unfreezeConfirmDescription', { name }),
confirmText: t('myNamespaces.unfreeze'),
successTitle: t('myNamespaces.unfreezeSuccessTitle'),
successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }),
errorTitle: t('myNamespaces.unfreezeErrorTitle'),
variant: 'default' as const,
}
}
if (action === 'archive') {
return {
title: t('myNamespaces.archiveConfirmTitle'),
description: t('myNamespaces.archiveConfirmDescription', { name }),
confirmText: t('myNamespaces.archive'),
successTitle: t('myNamespaces.archiveSuccessTitle'),
successDescription: t('myNamespaces.archiveSuccessDescription', { name }),
errorTitle: t('myNamespaces.archiveErrorTitle'),
variant: 'destructive' as const,
}
}
return {
title: t('myNamespaces.restoreConfirmTitle'),
description: t('myNamespaces.restoreConfirmDescription', { name }),
confirmText: t('myNamespaces.restore'),
successTitle: t('myNamespaces.restoreSuccessTitle'),
successDescription: t('myNamespaces.restoreSuccessDescription', { name }),
errorTitle: t('myNamespaces.restoreErrorTitle'),
variant: 'default' as const,
}
}
const handleNamespaceAction = async () => {
if (!pendingAction) {
return
}
const copy = resolveActionCopy(pendingAction.action, pendingAction.name)
try {
if (pendingAction.action === 'freeze') {
await freezeMutation.mutateAsync({ slug: pendingAction.slug })
} else if (pendingAction.action === 'unfreeze') {
await unfreezeMutation.mutateAsync({ slug: pendingAction.slug })
} else if (pendingAction.action === 'archive') {
await archiveMutation.mutateAsync({ slug: pendingAction.slug })
} else {
await restoreMutation.mutateAsync({ slug: pendingAction.slug })
}
toast.success(copy.successTitle, copy.successDescription)
setPendingAction(null)
} catch (error) {
toast.error(copy.errorTitle, error instanceof Error ? error.message : '')
throw error
}
}
if (isLoading) {
return (
<div className="space-y-4 animate-fade-up">
@ -63,6 +181,9 @@ export function MyNamespacesPage() {
type={namespace.type}
name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')}
/>
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${resolveStatusClassName(namespace.status)}`}>
{resolveStatusLabel(namespace.status)}
</span>
</div>
{namespace.description && (
<p className="text-sm text-muted-foreground mb-2 leading-relaxed">
@ -70,9 +191,15 @@ export function MyNamespacesPage() {
</p>
)}
<div className="text-sm text-muted-foreground font-mono">@{namespace.slug}</div>
<div className="mt-3 rounded-lg border border-border/50 bg-secondary/40 px-3 py-2 text-sm text-muted-foreground">
{resolveHint(namespace.status, namespace.type)}
</div>
<div className="mt-2 text-xs uppercase tracking-[0.18em] text-muted-foreground/80">
{t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')}
</div>
</div>
</div>
<div className="flex gap-3">
<div className="flex flex-wrap gap-3">
{namespace.type === 'TEAM' && (
<Button
variant="outline"
@ -89,6 +216,54 @@ export function MyNamespacesPage() {
>
{t('myNamespaces.reviewTasks')}
</Button>
{namespace.canFreeze && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'freeze', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.freeze')}
</Button>
)}
{namespace.canUnfreeze && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'unfreeze', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.unfreeze')}
</Button>
)}
{namespace.canArchive && (
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'archive', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.archive')}
</Button>
)}
{namespace.canRestore && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'restore', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.restore')}
</Button>
)}
</div>
</div>
</Card>
@ -101,6 +276,20 @@ export function MyNamespacesPage() {
action={<Button disabled>{t('myNamespaces.create')}</Button>}
/>
)}
<ConfirmDialog
open={!!pendingAction}
onOpenChange={(open) => {
if (!open) {
setPendingAction(null)
}
}}
title={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).title : ''}
description={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).description : ''}
confirmText={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).confirmText : undefined}
variant={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).variant : 'default'}
onConfirm={handleNamespaceAction}
/>
</div>
)
}

View file

@ -15,6 +15,14 @@ export function NamespaceMembersPage() {
const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug)
const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug)
const isReadOnly = namespace?.type === 'GLOBAL' || namespace?.status !== 'ACTIVE'
const readOnlyMessage = namespace?.type === 'GLOBAL'
? t('members.globalReadOnly')
: namespace?.status === 'FROZEN'
? t('members.frozenReadOnly')
: namespace?.status === 'ARCHIVED'
? t('members.archivedReadOnly')
: null
if (isLoadingNamespace) {
return (
@ -42,8 +50,14 @@ export function NamespaceMembersPage() {
<NamespaceHeader namespace={namespace} />
<div className="space-y-6">
{readOnlyMessage ? (
<Card className="border-border/50 bg-secondary/40 p-4 text-sm text-muted-foreground">
{readOnlyMessage}
</Card>
) : null}
<div className="flex items-center justify-end">
<Button disabled>{t('members.addMember')}</Button>
<Button disabled={isReadOnly}>{t('members.addMember')}</Button>
</div>
{isLoadingMembers ? (
@ -77,7 +91,7 @@ export function NamespaceMembersPage() {
{new Date(member.createdAt).toLocaleDateString(language)}
</td>
<td className="p-4 text-right">
<Button variant="destructive" size="sm" disabled>
<Button variant="destructive" size="sm" disabled={isReadOnly}>
{t('members.remove')}
</Button>
</td>

View file

@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { useNamespaceDetail } from '@/shared/hooks/use-skill-queries'
import { useReviewList } from '@/features/review/use-review-list'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { NamespaceHeader } from '@/features/namespace/namespace-header'
function ReviewListSection({ namespaceId }: { namespaceId?: number }) {
const { t } = useTranslation()
@ -54,6 +55,13 @@ export function NamespaceReviewsPage() {
const { t } = useTranslation()
const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' })
const { data: namespace } = useNamespaceDetail(slug)
const readOnlyMessage = namespace?.type === 'GLOBAL'
? t('nsReviews.globalReadOnly')
: namespace?.status === 'FROZEN'
? t('nsReviews.frozenReadOnly')
: namespace?.status === 'ARCHIVED'
? t('nsReviews.archivedReadOnly')
: null
return (
<div className="space-y-8 animate-fade-up">
@ -61,6 +69,12 @@ export function NamespaceReviewsPage() {
title={t('nsReviews.title')}
subtitle={namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')}
/>
{namespace ? <NamespaceHeader namespace={namespace} /> : null}
{readOnlyMessage ? (
<Card className="border-border/50 bg-secondary/40 p-4 text-sm text-muted-foreground">
{readOnlyMessage}
</Card>
) : null}
<ReviewListSection namespaceId={namespace?.id} />
</div>
)

View file

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, meApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace } from '@/api/types'
import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
@ -56,14 +56,12 @@ async function getMyStars(): Promise<SkillSummary[]> {
return meApi.getStars()
}
async function getMyNamespaces(): Promise<Namespace[]> {
const page = await fetchJson<PagedResponse<Namespace>>(`${WEB_API_PREFIX}/namespaces`)
return page.items
async function getMyNamespaces(): Promise<ManagedNamespace[]> {
return namespaceApi.listMine()
}
async function getNamespaceDetail(slug: string): Promise<Namespace> {
const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${cleanSlug}`)
return namespaceApi.getDetail(slug)
}
async function getNamespaceMembers(slug: string): Promise<NamespaceMember[]> {
@ -173,6 +171,13 @@ export function useNamespaceMembers(slug: string) {
})
}
function invalidateNamespaceQueries(queryClient: ReturnType<typeof useQueryClient>, slug: string) {
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
queryClient.invalidateQueries({ queryKey: ['namespaces', slug] })
queryClient.invalidateQueries({ queryKey: ['namespaces', slug, 'members'] })
queryClient.invalidateQueries({ queryKey: ['reviews'] })
}
export function usePublishSkill() {
const queryClient = useQueryClient()
@ -261,3 +266,47 @@ export function useRereleaseSkillVersion() {
},
})
}
export function useFreezeNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug }: { slug: string }) => namespaceApi.freeze(slug),
onSuccess: (_data, variables) => {
invalidateNamespaceQueries(queryClient, variables.slug)
},
})
}
export function useUnfreezeNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug }: { slug: string }) => namespaceApi.unfreeze(slug),
onSuccess: (_data, variables) => {
invalidateNamespaceQueries(queryClient, variables.slug)
},
})
}
export function useArchiveNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug, reason }: { slug: string; reason?: string }) => namespaceApi.archive(slug, reason),
onSuccess: (_data, variables) => {
invalidateNamespaceQueries(queryClient, variables.slug)
},
})
}
export function useRestoreNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ slug }: { slug: string }) => namespaceApi.restore(slug),
onSuccess: (_data, variables) => {
invalidateNamespaceQueries(queryClient, variables.slug)
},
})
}