mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
feat(namespace): implement frontend delete functionality with reason input
Add complete frontend implementation for namespace deletion: - API layer: add delete() method to namespaceApi - Hooks layer: add useDeleteNamespace() mutation hook - UI layer: add DeleteNamespaceDialog component with reason input - Add delete button in my-namespaces page (only shown when canDelete is true) - Add i18n keys for delete confirmation, reason input, and success/error messages - Update ManagedNamespace type to include canDelete permission flag - Fix test mocks to include canDelete property The delete dialog requires users to provide a deletion reason before confirming, following the backend requirement. After successful deletion, users are redirected to the my-namespaces page.
This commit is contained in:
parent
f36cd43689
commit
5cc41c4e92
8 changed files with 213 additions and 1 deletions
|
|
@ -732,6 +732,16 @@ export const namespaceApi = {
|
|||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async delete(slug: string, reason: string): Promise<void> {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`, {
|
||||
method: 'DELETE',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const tokenApi = {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export interface ManagedNamespace extends Namespace {
|
|||
canUnfreeze: boolean
|
||||
canArchive: boolean
|
||||
canRestore: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface NamespaceMember {
|
||||
|
|
|
|||
97
web/src/features/namespace/delete-namespace-dialog.tsx
Normal file
97
web/src/features/namespace/delete-namespace-dialog.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
|
||||
interface DeleteNamespaceDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
namespaceName: string
|
||||
onConfirm: (reason: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function DeleteNamespaceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
namespaceName,
|
||||
onConfirm,
|
||||
}: DeleteNamespaceDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [reason, setReason] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!reason.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onConfirm(reason)
|
||||
setReason('')
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!isSubmitting) {
|
||||
if (!newOpen) {
|
||||
setReason('')
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('namespace.delete.confirm.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('namespace.delete.confirm.description', { name: namespaceName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-reason">{t('namespace.delete.reason.label')}</Label>
|
||||
<Textarea
|
||||
id="delete-reason"
|
||||
placeholder={t('namespace.delete.reason.placeholder')}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-center sm:space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={!reason.trim() || isSubmitting}
|
||||
>
|
||||
{t('namespace.delete.button')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ describe('review-paths', () => {
|
|||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
currentUserRole: 'ADMIN',
|
||||
createdAt: '',
|
||||
},
|
||||
|
|
@ -59,6 +60,7 @@ describe('review-paths', () => {
|
|||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
currentUserRole: 'OWNER',
|
||||
createdAt: '',
|
||||
},
|
||||
|
|
@ -78,6 +80,7 @@ describe('review-paths', () => {
|
|||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
currentUserRole: 'MEMBER',
|
||||
createdAt: '',
|
||||
},
|
||||
|
|
@ -98,6 +101,7 @@ describe('review-paths', () => {
|
|||
canUnfreeze: false,
|
||||
canArchive: false,
|
||||
canRestore: false,
|
||||
canDelete: false,
|
||||
currentUserRole: 'ADMIN',
|
||||
createdAt: '',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -428,6 +428,24 @@
|
|||
"unfreeze": "Unfreeze",
|
||||
"archive": "Archive",
|
||||
"restore": "Restore",
|
||||
"delete": {
|
||||
"button": "Delete",
|
||||
"confirm": {
|
||||
"title": "Delete namespace",
|
||||
"description": "\"{{name}}\" will be permanently deleted. This action cannot be undone. Please provide a reason for deletion."
|
||||
},
|
||||
"reason": {
|
||||
"label": "Deletion reason",
|
||||
"placeholder": "Please explain why this namespace needs to be deleted..."
|
||||
},
|
||||
"success": {
|
||||
"title": "Namespace deleted",
|
||||
"description": "\"{{name}}\" has been permanently deleted."
|
||||
},
|
||||
"error": {
|
||||
"title": "Failed to delete namespace"
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -428,6 +428,24 @@
|
|||
"unfreeze": "解冻",
|
||||
"archive": "归档",
|
||||
"restore": "恢复",
|
||||
"delete": {
|
||||
"button": "删除",
|
||||
"confirm": {
|
||||
"title": "删除命名空间",
|
||||
"description": "\"{{name}}\"将被永久删除,此操作无法撤销。请提供删除原因。"
|
||||
},
|
||||
"reason": {
|
||||
"label": "删除原因",
|
||||
"placeholder": "请说明为什么需要删除此命名空间..."
|
||||
},
|
||||
"success": {
|
||||
"title": "命名空间已删除",
|
||||
"description": "\"{{name}}\"已被永久删除。"
|
||||
},
|
||||
"error": {
|
||||
"title": "删除命名空间失败"
|
||||
}
|
||||
},
|
||||
"activeHint": "命名空间运行正常,可继续管理成员、处理审核和发布技能。",
|
||||
"frozenHint": "命名空间已冻结。成员仍可查看,但当前处于只读状态,不能继续发布或变更成员。",
|
||||
"archivedHint": "命名空间已归档。公开入口已隐藏,但你仍可在管理台查看并恢复。",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import { EmptyState } from '@/shared/components/empty-state'
|
|||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog'
|
||||
import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-namespace-queries'
|
||||
import { useArchiveNamespace, useDeleteNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-namespace-queries'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { DeleteNamespaceDialog } from '@/features/namespace/delete-namespace-dialog'
|
||||
|
||||
type PendingNamespaceAction =
|
||||
| { action: 'freeze'; slug: string; name: string }
|
||||
|
|
@ -18,6 +19,11 @@ type PendingNamespaceAction =
|
|||
| { action: 'archive'; slug: string; name: string }
|
||||
| { action: 'restore'; slug: string; name: string }
|
||||
|
||||
type PendingDeleteAction = {
|
||||
slug: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard page for namespaces the current user can manage or review. It owns
|
||||
* namespace lifecycle actions because each action combines permissions, copy,
|
||||
|
|
@ -29,11 +35,13 @@ export function MyNamespacesPage() {
|
|||
const { hasRole } = useAuth()
|
||||
const canCreateNamespace = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const [pendingAction, setPendingAction] = useState<PendingNamespaceAction | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDeleteAction | null>(null)
|
||||
const { data: namespaces, isLoading } = useMyNamespaces()
|
||||
const freezeMutation = useFreezeNamespace()
|
||||
const unfreezeMutation = useUnfreezeNamespace()
|
||||
const archiveMutation = useArchiveNamespace()
|
||||
const restoreMutation = useRestoreNamespace()
|
||||
const deleteMutation = useDeleteNamespace()
|
||||
|
||||
const handleNamespaceClick = (slug: string) => {
|
||||
navigate({ to: `/space/${encodeURIComponent(slug)}` })
|
||||
|
|
@ -157,6 +165,28 @@ export function MyNamespacesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleDeleteNamespace = async (reason: string) => {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ slug: pendingDelete.slug, reason })
|
||||
toast.success(
|
||||
t('namespace.delete.success.title'),
|
||||
t('namespace.delete.success.description', { name: pendingDelete.name }),
|
||||
)
|
||||
setPendingDelete(null)
|
||||
navigate({ to: '/dashboard/namespaces' })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t('namespace.delete.error.title'),
|
||||
error instanceof Error ? error.message : '',
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
|
|
@ -281,6 +311,18 @@ export function MyNamespacesPage() {
|
|||
{t('myNamespaces.restore')}
|
||||
</Button>
|
||||
)}
|
||||
{namespace.canDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete({ slug: namespace.slug, name: namespace.displayName })
|
||||
}}
|
||||
>
|
||||
{t('namespace.delete.button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -311,6 +353,17 @@ export function MyNamespacesPage() {
|
|||
variant={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).variant : 'default'}
|
||||
onConfirm={handleNamespaceAction}
|
||||
/>
|
||||
|
||||
<DeleteNamespaceDialog
|
||||
open={!!pendingDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingDelete(null)
|
||||
}
|
||||
}}
|
||||
namespaceName={pendingDelete?.name ?? ''}
|
||||
onConfirm={handleDeleteNamespace}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,3 +186,14 @@ export function useRestoreNamespace() {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteNamespace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ slug, reason }: { slug: string; reason: string }) => namespaceApi.delete(slug, reason),
|
||||
onSuccess: (_data, variables) => {
|
||||
invalidateNamespaceQueries(queryClient, variables.slug)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue