mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
feat: add namespace governance dashboard
This commit is contained in:
parent
33ed8fe17d
commit
f92cdb31f2
10 changed files with 461 additions and 21 deletions
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}})"
|
||||
|
|
|
|||
|
|
@ -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}})"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue