diff --git a/app/(admin)/_components/admin-layout-shell.tsx b/app/(admin)/_components/admin-layout-shell.tsx new file mode 100644 index 000000000..d1a2fccb9 --- /dev/null +++ b/app/(admin)/_components/admin-layout-shell.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { ComponentProps, ReactNode } from "react"; +import { usePathname } from "next/navigation"; +import { TopNavbar } from "@/components/top-navbar"; + +type AdminLayoutShellProps = ComponentProps & { + children: ReactNode; +}; + +function shouldHideSidebarChrome(pathname: string) { + return pathname === "/settings" || pathname.startsWith("/settings/"); +} + +export function AdminLayoutShell({ + children, + ...topNavbarProps +}: AdminLayoutShellProps) { + const pathname = usePathname(); + const hideSidebarChrome = shouldHideSidebarChrome(pathname); + + return ( +
+ {hideSidebarChrome ? null : } +
+ {children} +
+
+ ); +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index fd960eca7..703e03359 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,6 +1,6 @@ import { auth } from "@/app/(auth)/auth"; -import { TopNavbar } from "@/components/top-navbar"; import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries"; +import { AdminLayoutShell } from "./_components/admin-layout-shell"; export default async function AdminLayout({ children, @@ -21,38 +21,37 @@ export default async function AdminLayout({ : null; return ( -
- ({ - id: link.id, - name: link.name, - url: link.url, - order: link.order, - toolboxGroup: link.toolboxGroup || 1, - }))} - toolbox1Name={siteSettings.toolbox1Name || "工具箱"} - toolbox1Enabled={siteSettings.toolbox1Enabled ?? true} - toolbox2Name={siteSettings.toolbox2Name || "工具箱2"} - toolbox2Enabled={siteSettings.toolbox2Enabled ?? false} - tutorialVideos={tutorialVideos.map(v => ({ - id: v.id, - title: v.title, - videoUrl: v.videoUrl, - coverUrl: v.coverUrl, - description: v.description, - order: v.order, - }))} - tutorialEnabled={siteSettings.tutorialEnabled ?? false} - rankingEnabled={siteSettings.rankingEnabled ?? true} - productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true} - videoCanvasEnabled={(siteSettings as any).videoCanvasEnabled ?? true} - /> -
{children}
-
+ ({ + id: link.id, + name: link.name, + url: link.url, + order: link.order, + toolboxGroup: link.toolboxGroup || 1, + }))} + toolbox1Name={siteSettings.toolbox1Name || "工具箱"} + toolbox1Enabled={siteSettings.toolbox1Enabled ?? true} + toolbox2Name={siteSettings.toolbox2Name || "工具箱2"} + toolbox2Enabled={siteSettings.toolbox2Enabled ?? false} + tutorialVideos={tutorialVideos.map((v) => ({ + id: v.id, + title: v.title, + videoUrl: v.videoUrl, + coverUrl: v.coverUrl, + description: v.description, + order: v.order, + }))} + tutorialEnabled={siteSettings.tutorialEnabled ?? false} + rankingEnabled={siteSettings.rankingEnabled ?? true} + productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true} + videoCanvasEnabled={(siteSettings as any).videoCanvasEnabled ?? true} + > + {children} + ); } diff --git a/app/(admin)/settings/_components/admin-settings-navigation.tsx b/app/(admin)/settings/_components/admin-settings-navigation.tsx index 5fc9010c0..346e073b7 100644 --- a/app/(admin)/settings/_components/admin-settings-navigation.tsx +++ b/app/(admin)/settings/_components/admin-settings-navigation.tsx @@ -1,90 +1,155 @@ "use client"; import Link from "next/link"; -import { TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation"; -import { SettingStatusBadge } from "./setting-status-badge"; +import type { AdminSettingsNavItem } from "../_lib/types"; + +const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items); + +function SettingsQuickAction({ + activeTab, + item, + onTabChange, + stretched = false, +}: { + activeTab: string; + item: AdminSettingsNavItem; + onTabChange: (value: string) => void; + stretched?: boolean; +}) { + const Icon = item.icon; + const className = + item.value === activeTab + ? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black" + : "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50"; + const layoutClassName = stretched + ? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4" + : "h-9 shrink-0 gap-2 rounded-full border px-3.5"; + + if (item.href) { + return ( + + ); + } -export function AdminSettingsNavigation({ activeTab }: { activeTab: string }) { return ( -
-
-
-
- Admin Console -
-

- 系统设置中心 -

-

- 按后台人员的操作路径重新组织入口:先处理运营数据,再调整站点体验,最后管理模型与基础设施能力。 -

+ + ); +} + +export function AdminSettingsNavigation({ + activeTab, + onTabChange, +}: { + activeTab: string; + onTabChange: (value: string) => void; +}) { + const router = useRouter(); + const activeItem = ALL_SETTINGS_ITEMS.find((item) => item.value === activeTab); + const handleSelectChange = (value: string) => { + const nextItem = ALL_SETTINGS_ITEMS.find((item) => item.value === value); + if (nextItem?.href) { + router.push(nextItem.href); + return; + } + onTabChange(value); + }; + + return ( +
+
+
+ + + +
+ +
+ {ALL_SETTINGS_ITEMS.map((item) => ( + + ))}
-
- {ADMIN_SETTINGS_NAV_GROUPS.map((group) => { - const hasActiveTab = group.items.some( - (item) => item.value === activeTab - ); - return ( -
-
-
-

{group.title}

-

- {group.description} -

-
- {hasActiveTab ? ( - 当前 - ) : null} -
- - {group.items.map((item) => { - const Icon = item.icon; - const triggerClassName = - "h-auto justify-start gap-2 rounded-md border border-transparent px-3 py-2 text-left data-[state=active]:border-border data-[state=active]:bg-muted/50"; - const content = ( - <> - - - - {item.label} - - - {item.description} - - - - ); - if (item.href) { - return ( - - {content} - - ); - } +
+
+ {ALL_SETTINGS_ITEMS.map((item) => ( +
+ +
+ ))} +
- return ( - - {content} - - ); - })} - -
- ); - })} +
); diff --git a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx index c2ef656c8..cffff1672 100644 --- a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx @@ -5,13 +5,17 @@ import { ArrowUp, Check, Layers, + Loader2, Menu, Move3D, Plus, RefreshCw, Save, + Sparkles, Trash2, + Upload, } from "lucide-react"; +import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -26,6 +30,10 @@ import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import { + AI_WASH_CONTROL_ORDER, + type AiWashControlId, +} from "@/lib/ai-wash/config"; import { ENHANCE_PRO_KREA_DEFAULT_BASE_URL, ENHANCE_PRO_KREA_DEFAULT_MODEL, @@ -45,6 +53,7 @@ import { } from "../local-ai-section-shell"; export function LocalAiImagePipelineSections({ + modelsData, rmbgConfig, setRmbgConfig, enhanceConfig, @@ -63,6 +72,9 @@ export function LocalAiImagePipelineSections({ selectedEnhanceProPreset, selectedEnhanceProPresetIndex, draggingEnhanceProPresetId, + aiWashConfig, + setAiWashConfig, + aiWashModeIconUploading, extendConfig, setExtendConfig, watermarkRemoveConfig, @@ -87,11 +99,26 @@ export function LocalAiImagePipelineSections({ handleEnhanceProPresetDragEnd, handleEnhanceProPresetDrop, handleMoveEnhanceProPreset, + handleAiWashModeIconUpload, + handleSaveAiWashConfig, handleSaveExtendConfig, handleSaveWatermarkRemoveConfig, handleSaveAngleEditConfig, handleSaveLayerSplitConfig, }: LocalAiImagePipelineSectionsProps) { + const availableImageModels = (modelsData?.models || []).filter( + (model) => model.supportsImageGen && model.isEnabled && !model.isHidden + ); + const getAiWashControlToneCopy = (controlId: AiWashControlId) => { + if (controlId === "creativity") { + return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。"; + } + if (controlId === "detail") { + return "每档可以补充默认提示词,决定细节精修的强弱。"; + } + return "每档可以补充默认提示词,决定光影、氛围与空间塑造。"; + }; + return ( <> {/* 抠图API配置 */} @@ -1385,8 +1412,775 @@ export function LocalAiImagePipelineSections({
- + + + + 配置无限画布“AI洗图”子页面使用的生图模型与提示词。前台会基于同一张原图并行生成多组洗图结果,并支持拖动对比。 + + } + expanded={isLocalAiSectionExpanded("ai-wash")} + id="local-ai-section-ai-wash" + onToggle={() => toggleLocalAiSection("ai-wash")} + summary={ + preset.enabled).length} 个启用预设`, + tone: + aiWashConfig.presets.some( + (preset) => + preset.enabled && preset.modelId.trim() && preset.prompt.trim() + ) + ? "enabled" + : "warning", + }, + { + label: aiWashConfig.modeLabel || "AI洗图", + tone: "neutral", + }, + ]} + /> + } + title="AI洗图配置" + > +
+
+ +
+ 开启后,图像增强模式顶部会新增一个“AI洗图”入口,用户可上传底图并批量对比洗图结果。 +
+
+
+ + setAiWashConfig((prev) => ({ + ...prev, + enabled: checked, + })) + } + /> + + {aiWashConfig.enabled ? "已开启" : "已关闭"} + +
+
+ +
+
+
+
+
前台入口名称与图标
+
+ 会同步到图像增强顶部的 AI洗图模式按钮,也会用于洗图工作区标题。 +
+
+
+ + {aiWashConfig.modeIconUrl ? ( + + ) : null} +
+ { + const file = event.target.files?.[0]; + if (file) { + await handleAiWashModeIconUpload(file); + } + event.target.value = ""; + }} + type="file" + /> +
+ +
+
+ + + setAiWashConfig((prev) => ({ + ...prev, + modeLabel: event.target.value, + })) + } + placeholder="AI洗图" + value={aiWashConfig.modeLabel} + /> +
+
+ +
+
+ + {aiWashConfig.modeLabel || "AI洗图"} +
+
+ + {aiWashConfig.modeLabel || "AI洗图"} +
+
+
+
+
+
+ + +
+
+ + + setAiWashConfig((prev) => ({ + ...prev, + historyLimit: Number(event.target.value || 8), + })) + } + type="number" + value={aiWashConfig.historyLimit} + /> +
+
+
+
启用提示词扩写
+
+ 会将预设提示词交给生图供应商做额外 prompt enhance。 +
+
+ + setAiWashConfig((prev) => ({ + ...prev, + promptExtend: checked, + })) + } + /> +
+
+ +
+
+
+
洗图预设
+
+ 前台会横向展示启用中的预设结果。建议至少配置 2 到 3 个不同风格的结果卡位。 +
+
+
+ 共 {aiWashConfig.presets.length} 个预设 +
+
+ +
+ {aiWashConfig.presets.map((preset, index) => ( +
+
+
+
{preset.title || `预设 ${index + 1}`}
+
+ 这个卡位会在前台结果区展示成一张独立的可拖动对比卡片。 +
+
+
+ +
+ + setAiWashConfig((prev) => ({ + ...prev, + presets: prev.presets.map((item, itemIndex) => + itemIndex === index + ? { ...item, enabled: checked } + : item + ), + })) + } + /> + + {preset.enabled ? "启用" : "停用"} + +
+
+
+ +
+
+ + + setAiWashConfig((prev) => ({ + ...prev, + presets: prev.presets.map((item, itemIndex) => + itemIndex === index + ? { ...item, title: event.target.value } + : item + ), + })) + } + placeholder="例如:质感提升(细节更精准)" + value={preset.title} + /> +
+
+ + +
+ 如果这里指定了模型,前台默认不再展示生图模型下拉;创造力档位里若再指定模型,会以档位模型优先。 +
+
+
+ +
+
+ +