mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
feat: add namespace creation dialog
This commit is contained in:
parent
93323766db
commit
889a6d3b9d
7 changed files with 335 additions and 3 deletions
|
|
@ -21,6 +21,7 @@ import type {
|
|||
User,
|
||||
ManagedNamespace,
|
||||
Namespace,
|
||||
CreateNamespaceRequest,
|
||||
} from './types'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import i18n from '@/i18n/config'
|
||||
|
|
@ -467,6 +468,20 @@ function normalizeNamespaceSlug(namespace: string): string {
|
|||
}
|
||||
|
||||
export const namespaceApi = {
|
||||
async create(request: CreateNamespaceRequest): Promise<Namespace> {
|
||||
const namespace = await unwrap<Namespace>(client.POST('/api/v1/namespaces', {
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: {
|
||||
slug: normalizeNamespaceSlug(request.slug),
|
||||
displayName: request.displayName.trim(),
|
||||
description: request.description?.trim() || undefined,
|
||||
},
|
||||
} as never) as never)
|
||||
return namespace
|
||||
},
|
||||
|
||||
async listMine(): Promise<ManagedNamespace[]> {
|
||||
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ export interface ChangePasswordRequest {
|
|||
newPassword: string
|
||||
}
|
||||
|
||||
export type CreateNamespaceRequest = Omit<components['schemas']['NamespaceRequest'], 'slug' | 'displayName'> & {
|
||||
slug: string
|
||||
displayName: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface MergeInitiateRequest {
|
||||
secondaryIdentifier: string
|
||||
}
|
||||
|
|
|
|||
239
web/src/features/namespace/create-namespace-dialog.tsx
Normal file
239
web/src/features/namespace/create-namespace-dialog.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CreateNamespaceRequest } from '@/api/types'
|
||||
import { useCreateNamespace } from '@/shared/hooks/use-skill-queries'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
|
||||
interface CreateNamespaceDialogProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type FieldErrors = {
|
||||
slug?: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
||||
const RESERVED_SLUGS = new Set([
|
||||
'admin',
|
||||
'api',
|
||||
'dashboard',
|
||||
'search',
|
||||
'auth',
|
||||
'me',
|
||||
'global',
|
||||
'system',
|
||||
'static',
|
||||
'assets',
|
||||
'health',
|
||||
])
|
||||
const MAX_SLUG_LENGTH = 64
|
||||
const MIN_SLUG_LENGTH = 2
|
||||
const MAX_DISPLAY_NAME_LENGTH = 128
|
||||
const MAX_DESCRIPTION_LENGTH = 512
|
||||
|
||||
function buildFieldErrors(request: CreateNamespaceRequest, t: (key: string, options?: Record<string, unknown>) => string): FieldErrors {
|
||||
const errors: FieldErrors = {}
|
||||
const slug = request.slug.trim()
|
||||
const displayName = request.displayName.trim()
|
||||
const description = request.description?.trim() ?? ''
|
||||
|
||||
if (!slug) {
|
||||
errors.slug = t('myNamespaces.createSlugRequired')
|
||||
} else if (slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) {
|
||||
errors.slug = t('myNamespaces.createSlugLength', { min: MIN_SLUG_LENGTH, max: MAX_SLUG_LENGTH })
|
||||
} else if (!SLUG_PATTERN.test(slug)) {
|
||||
errors.slug = t('myNamespaces.createSlugPattern')
|
||||
} else if (slug.includes('--')) {
|
||||
errors.slug = t('myNamespaces.createSlugDoubleHyphen')
|
||||
} else if (RESERVED_SLUGS.has(slug)) {
|
||||
errors.slug = t('myNamespaces.createSlugReserved', { slug })
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
errors.displayName = t('myNamespaces.createDisplayNameRequired')
|
||||
} else if (displayName.length > MAX_DISPLAY_NAME_LENGTH) {
|
||||
errors.displayName = t('myNamespaces.createDisplayNameLength', { max: MAX_DISPLAY_NAME_LENGTH })
|
||||
}
|
||||
|
||||
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||||
errors.description = t('myNamespaces.createDescriptionLength', { max: MAX_DESCRIPTION_LENGTH })
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export function CreateNamespaceDialog({ children }: CreateNamespaceDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const createMutation = useCreateNamespace()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [slug, setSlug] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [errors, setErrors] = useState<FieldErrors>({})
|
||||
|
||||
const resetDialog = () => {
|
||||
setSlug('')
|
||||
setDisplayName('')
|
||||
setDescription('')
|
||||
setErrors({})
|
||||
createMutation.reset()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
resetDialog()
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRequest: CreateNamespaceRequest = {
|
||||
slug: slug.trim().toLowerCase(),
|
||||
displayName: displayName.trim(),
|
||||
description: description.trim() || undefined,
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const nextErrors = buildFieldErrors(normalizedRequest, t)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
setErrors(nextErrors)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const namespace = await createMutation.mutateAsync(normalizedRequest)
|
||||
toast.success(
|
||||
t('myNamespaces.createSuccessTitle'),
|
||||
t('myNamespaces.createSuccessDescription', { name: namespace.displayName }),
|
||||
)
|
||||
handleOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error(t('myNamespaces.createErrorTitle'), error instanceof Error ? error.message : '')
|
||||
}
|
||||
}
|
||||
|
||||
const slugLength = slug.trim().length
|
||||
const displayNameLength = displayName.trim().length
|
||||
const descriptionLength = description.trim().length
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{t('myNamespaces.createDialogTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{t('myNamespaces.createDialogDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="namespace-slug">{t('myNamespaces.createSlugLabel')}</Label>
|
||||
<Input
|
||||
id="namespace-slug"
|
||||
placeholder={t('myNamespaces.createSlugPlaceholder')}
|
||||
value={slug}
|
||||
maxLength={MAX_SLUG_LENGTH}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setSlug(event.target.value.toLowerCase())
|
||||
if (errors.slug) {
|
||||
setErrors((current) => ({ ...current, slug: undefined }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
aria-invalid={errors.slug ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-red-600">{errors.slug ?? t('myNamespaces.createSlugHint')}</span>
|
||||
<span className="text-muted-foreground">{slugLength}/{MAX_SLUG_LENGTH}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="namespace-display-name">{t('myNamespaces.createDisplayNameLabel')}</Label>
|
||||
<Input
|
||||
id="namespace-display-name"
|
||||
placeholder={t('myNamespaces.createDisplayNamePlaceholder')}
|
||||
value={displayName}
|
||||
maxLength={MAX_DISPLAY_NAME_LENGTH}
|
||||
onChange={(event) => {
|
||||
setDisplayName(event.target.value)
|
||||
if (errors.displayName) {
|
||||
setErrors((current) => ({ ...current, displayName: undefined }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
aria-invalid={errors.displayName ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-red-600">{errors.displayName ?? ''}</span>
|
||||
<span className="text-muted-foreground">{displayNameLength}/{MAX_DISPLAY_NAME_LENGTH}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="namespace-description">{t('myNamespaces.createDescriptionLabel')}</Label>
|
||||
<Textarea
|
||||
id="namespace-description"
|
||||
placeholder={t('myNamespaces.createDescriptionPlaceholder')}
|
||||
value={description}
|
||||
maxLength={MAX_DESCRIPTION_LENGTH}
|
||||
onChange={(event) => {
|
||||
setDescription(event.target.value)
|
||||
if (errors.description) {
|
||||
setErrors((current) => ({ ...current, description: undefined }))
|
||||
}
|
||||
}}
|
||||
aria-invalid={errors.description ? 'true' : 'false'}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-red-600">{errors.description ?? t('myNamespaces.createDescriptionHint')}</span>
|
||||
<span className="text-muted-foreground">{descriptionLength}/{MAX_DESCRIPTION_LENGTH}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.error ? (
|
||||
<p className="text-sm text-red-600">{createMutation.error.message}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-center sm:space-x-3">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? t('myNamespaces.creating') : t('myNamespaces.createSubmit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -263,6 +263,29 @@
|
|||
"title": "My Namespaces",
|
||||
"subtitle": "Manage your namespaces and teams",
|
||||
"create": "Create Namespace",
|
||||
"creating": "Creating...",
|
||||
"createSubmit": "Create Now",
|
||||
"createDialogTitle": "Create Team Namespace",
|
||||
"createDialogDescription": "You will become the OWNER after creation and can continue managing members, reviews, and skill publishing.",
|
||||
"createSlugLabel": "Namespace slug",
|
||||
"createSlugPlaceholder": "e.g. team-ml",
|
||||
"createSlugHint": "Use 2-64 lowercase letters, numbers, or hyphens, without leading or trailing hyphens.",
|
||||
"createSlugRequired": "Namespace slug is required",
|
||||
"createSlugLength": "Namespace slug must be between {{min}} and {{max}} characters",
|
||||
"createSlugPattern": "Namespace slug can only contain lowercase letters, numbers, or hyphens",
|
||||
"createSlugDoubleHyphen": "Namespace slug cannot contain consecutive hyphens",
|
||||
"createSlugReserved": "\"{{slug}}\" is reserved. Please choose another slug.",
|
||||
"createDisplayNameLabel": "Display name",
|
||||
"createDisplayNamePlaceholder": "e.g. Machine Learning Team",
|
||||
"createDisplayNameRequired": "Display name is required",
|
||||
"createDisplayNameLength": "Display name must be at most {{max}} characters",
|
||||
"createDescriptionLabel": "Description",
|
||||
"createDescriptionPlaceholder": "Briefly describe what this namespace is for, who uses it, or how it is governed",
|
||||
"createDescriptionHint": "Optional, up to 512 characters.",
|
||||
"createDescriptionLength": "Description must be at most {{max}} characters",
|
||||
"createSuccessTitle": "Namespace created",
|
||||
"createSuccessDescription": "\"{{name}}\" is ready. You can now manage members or publish skills.",
|
||||
"createErrorTitle": "Failed to create namespace",
|
||||
"typeGlobal": "Global",
|
||||
"typeTeam": "Team",
|
||||
"roleLabel": "Current role",
|
||||
|
|
|
|||
|
|
@ -263,6 +263,29 @@
|
|||
"title": "我的命名空间",
|
||||
"subtitle": "管理你的命名空间和团队",
|
||||
"create": "创建命名空间",
|
||||
"creating": "创建中...",
|
||||
"createSubmit": "立即创建",
|
||||
"createDialogTitle": "创建团队命名空间",
|
||||
"createDialogDescription": "创建后你会自动成为 OWNER,可以继续管理成员、审核和技能发布。",
|
||||
"createSlugLabel": "命名空间标识",
|
||||
"createSlugPlaceholder": "例如 team-ml",
|
||||
"createSlugHint": "使用 2-64 位小写字母、数字或连字符,且不能以连字符开头或结尾。",
|
||||
"createSlugRequired": "请输入命名空间标识",
|
||||
"createSlugLength": "命名空间标识长度需在 {{min}} 到 {{max}} 位之间",
|
||||
"createSlugPattern": "命名空间标识只能包含小写字母、数字或连字符",
|
||||
"createSlugDoubleHyphen": "命名空间标识不能包含连续连字符",
|
||||
"createSlugReserved": "“{{slug}}”是保留标识,请换一个名称",
|
||||
"createDisplayNameLabel": "显示名称",
|
||||
"createDisplayNamePlaceholder": "例如 Machine Learning Team",
|
||||
"createDisplayNameRequired": "请输入显示名称",
|
||||
"createDisplayNameLength": "显示名称最多 {{max}} 个字符",
|
||||
"createDescriptionLabel": "描述",
|
||||
"createDescriptionPlaceholder": "简要说明这个命名空间的用途、成员范围或治理规则",
|
||||
"createDescriptionHint": "可选,最多 512 个字符。",
|
||||
"createDescriptionLength": "描述最多 {{max}} 个字符",
|
||||
"createSuccessTitle": "命名空间已创建",
|
||||
"createSuccessDescription": "“{{name}}”已经准备好,可以继续管理成员或发布技能。",
|
||||
"createErrorTitle": "创建命名空间失败",
|
||||
"typeGlobal": "全局",
|
||||
"typeTeam": "团队",
|
||||
"roleLabel": "当前角色",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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 { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog'
|
||||
import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-skill-queries'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
||||
|
|
@ -159,7 +160,11 @@ export function MyNamespacesPage() {
|
|||
<DashboardPageHeader
|
||||
title={t('myNamespaces.title')}
|
||||
subtitle={t('myNamespaces.subtitle')}
|
||||
actions={<Button disabled>{t('myNamespaces.create')}</Button>}
|
||||
actions={(
|
||||
<CreateNamespaceDialog>
|
||||
<Button>{t('myNamespaces.create')}</Button>
|
||||
</CreateNamespaceDialog>
|
||||
)}
|
||||
/>
|
||||
|
||||
{namespaces && namespaces.length > 0 ? (
|
||||
|
|
@ -273,7 +278,11 @@ export function MyNamespacesPage() {
|
|||
<EmptyState
|
||||
title={t('myNamespaces.emptyTitle')}
|
||||
description={t('myNamespaces.emptyDescription')}
|
||||
action={<Button disabled>{t('myNamespaces.create')}</Button>}
|
||||
action={(
|
||||
<CreateNamespaceDialog>
|
||||
<Button>{t('myNamespaces.create')}</Button>
|
||||
</CreateNamespaceDialog>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace } from '@/api/types'
|
||||
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest } from '@/api/types'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
|
||||
|
||||
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
|
@ -60,6 +60,10 @@ async function getMyNamespaces(): Promise<ManagedNamespace[]> {
|
|||
return namespaceApi.listMine()
|
||||
}
|
||||
|
||||
async function createNamespace(request: CreateNamespaceRequest): Promise<Namespace> {
|
||||
return namespaceApi.create(request)
|
||||
}
|
||||
|
||||
async function getNamespaceDetail(slug: string): Promise<Namespace> {
|
||||
return namespaceApi.getDetail(slug)
|
||||
}
|
||||
|
|
@ -155,6 +159,19 @@ export function useMyNamespaces() {
|
|||
})
|
||||
}
|
||||
|
||||
export function useCreateNamespace() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createNamespace,
|
||||
onSuccess: (namespace) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces', namespace.slug] })
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useNamespaceDetail(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['namespaces', slug],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue