优化项目启动器的创建功能

This commit is contained in:
xintaofei
2026-03-27 15:08:53 +08:00
parent 06580b0c9c
commit 3b080c801b
18 changed files with 374 additions and 55 deletions

View File

@@ -1,19 +1,37 @@
"use client"
import { useState } from "react"
import { useState, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl"
import { Loader2, FolderOpen } from "lucide-react"
import {
Loader2,
FolderOpen,
ChevronsUpDown,
CircleCheck,
CircleX,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Field,
FieldContent,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Dialog,
DialogContent,
@@ -22,9 +40,17 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
import { openFileDialog } from "@/lib/platform"
import { createShadcnProject, openFolderWindow } from "@/lib/api"
import {
createShadcnProject,
openFolderWindow,
detectPackageManager,
} from "@/lib/api"
import { toErrorMessage } from "@/lib/app-error"
import { FRAMEWORK_OPTIONS, PACKAGE_MANAGER_OPTIONS } from "./constants"
import {
BASE_OPTIONS,
FRAMEWORK_OPTIONS,
PACKAGE_MANAGER_OPTIONS,
} from "./constants"
interface CreateProjectDialogProps {
open: boolean
@@ -42,9 +68,38 @@ export function CreateProjectDialog({
const [framework, setFramework] = useState("next")
const [packageManager, setPackageManager] = useState("pnpm")
const [saveDirectory, setSaveDirectory] = useState("")
const [base, setBase] = useState("radix")
const [rtl, setRtl] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pmVersion, setPmVersion] = useState<string | null>(null)
const [pmInstalled, setPmInstalled] = useState<boolean | null>(null)
const [pmChecking, setPmChecking] = useState(false)
const checkPackageManager = useCallback(async (name: string) => {
setPmChecking(true)
setPmInstalled(null)
setPmVersion(null)
try {
const info = await detectPackageManager(name)
setPmInstalled(info.installed)
setPmVersion(info.version ?? null)
} catch {
setPmInstalled(false)
setPmVersion(null)
} finally {
setPmChecking(false)
}
}, [])
useEffect(() => {
if (open) {
checkPackageManager(packageManager)
}
}, [open, packageManager, checkPackageManager])
const handleBrowse = async () => {
const result = await openFileDialog({ directory: true, multiple: false })
if (!result) return
@@ -81,11 +136,18 @@ export function CreateProjectDialog({
setFramework("next")
setPackageManager("pnpm")
setSaveDirectory("")
setBase("radix")
setRtl(false)
setAdvancedOpen(false)
setError(null)
setPmVersion(null)
setPmInstalled(null)
}
const canCreate =
projectName.trim().length > 0 && saveDirectory.trim().length > 0
projectName.trim().length > 0 &&
saveDirectory.trim().length > 0 &&
pmInstalled === true
return (
<Dialog
@@ -111,46 +173,6 @@ export function CreateProjectDialog({
/>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.frameworkTemplate")}</Label>
<Select
value={framework}
onValueChange={setFramework}
disabled={creating}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FRAMEWORK_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.packageManager")}</Label>
<Select
value={packageManager}
onValueChange={setPackageManager}
disabled={creating}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.saveDirectory")}</Label>
<div className="flex gap-2">
@@ -173,6 +195,135 @@ export function CreateProjectDialog({
</div>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.packageManager")}</Label>
<Tabs
value={packageManager}
onValueChange={setPackageManager}
className="gap-0"
>
<TabsList className="w-full">
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
<TabsTrigger
key={opt.value}
value={opt.value}
className="flex-1"
disabled={creating}
>
{opt.label}
</TabsTrigger>
))}
</TabsList>
{PACKAGE_MANAGER_OPTIONS.map((opt) => (
<TabsContent key={opt.value} value={opt.value}>
<div className="flex h-8 items-center gap-1.5 text-sm">
{pmChecking ? (
<>
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">
{t("createDialog.pmChecking")}
</span>
</>
) : pmInstalled ? (
<>
<CircleCheck className="size-3.5 text-emerald-500" />
<span className="text-muted-foreground">
{opt.label} v{pmVersion}
</span>
</>
) : (
<>
<CircleX className="size-3.5 text-destructive" />
<span className="text-muted-foreground">
{t("createDialog.pmNotInstalled")}
</span>
</>
)}
</div>
</TabsContent>
))}
</Tabs>
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-auto gap-1 px-0 text-xs text-muted-foreground"
disabled={creating}
>
<ChevronsUpDown className="size-3.5" />
{t("createDialog.advancedOptions")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>{t("createDialog.frameworkTemplate")}</Label>
<RadioGroup
value={framework}
onValueChange={setFramework}
disabled={creating}
className="grid grid-cols-2 gap-2"
>
{FRAMEWORK_OPTIONS.map((opt) => (
<FieldLabel key={opt.value} htmlFor={`fw-${opt.value}`}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{opt.label}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={opt.value}
id={`fw-${opt.value}`}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<div className="space-y-1.5">
<Label>{t("createDialog.base")}</Label>
<RadioGroup
value={base}
onValueChange={setBase}
disabled={creating}
className="grid grid-cols-2 gap-2"
>
{BASE_OPTIONS.map((opt) => (
<FieldLabel key={opt.value} htmlFor={`base-${opt.value}`}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{opt.label}</FieldTitle>
</FieldContent>
<RadioGroupItem
value={opt.value}
id={`base-${opt.value}`}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-3">
<Switch
checked={rtl}
onCheckedChange={setRtl}
disabled={creating}
/>
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("createDialog.enableRtl")}
</div>
<div className="text-xs text-muted-foreground">
{t("createDialog.enableRtlDescription")}
</div>
</div>
</label>
</CollapsibleContent>
</Collapsible>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}

View File

@@ -13,7 +13,6 @@ import {
} from "@/components/ui/select"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
BASE_OPTIONS,
STYLE_OPTIONS,
BASE_COLOR_OPTIONS,
THEME_OPTIONS,
@@ -35,7 +34,6 @@ interface ShadcnConfigPanelProps {
}
type ConfigI18nKey =
| "config.base"
| "config.style"
| "config.baseColor"
| "config.theme"
@@ -53,7 +51,6 @@ const CONFIG_FIELDS: {
i18nKey: ConfigI18nKey
options: { value: string; label: string }[]
}[] = [
{ key: "base", i18nKey: "config.base", options: BASE_OPTIONS },
{ key: "style", i18nKey: "config.style", options: STYLE_OPTIONS },
{ key: "baseColor", i18nKey: "config.baseColor", options: BASE_COLOR_OPTIONS },
{ key: "theme", i18nKey: "config.theme", options: THEME_OPTIONS },

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid w-full gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -63,7 +63,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-full flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",