fix: restore canvas skills and local uploads, simplify home and canvas headers
This commit is contained in:
parent
c1c7037f05
commit
aed5e1c977
@ -250,7 +250,6 @@ import type {
|
||||
AgentWorkflowUiConfig,
|
||||
} from "@/types/agent";
|
||||
import type {
|
||||
AgentSlotNodeData,
|
||||
CanvasEdge,
|
||||
CanvasInitialProject,
|
||||
CanvasNode,
|
||||
@ -7515,7 +7514,7 @@ export default function CanvasEditorLegacyPage({
|
||||
materialMimeType: image.mimeType || undefined,
|
||||
originalHeight: dimensions.originalHeight,
|
||||
originalWidth: dimensions.originalWidth,
|
||||
source: "agent-workflow-material-search",
|
||||
source: image.sourceType === "upload" ? "local-upload" : "agent-workflow-material-search",
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
@ -7527,7 +7526,7 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
if (createdNodeIds.length > 0) {
|
||||
selectNodes(createdNodeIds);
|
||||
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
|
||||
toast.success(`已添加 ${createdNodeIds.length} 张${validImages.every((image) => image.sourceType === "upload") ? "图片" : "白底图"}到画布`);
|
||||
}
|
||||
},
|
||||
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
|
||||
@ -7543,64 +7542,6 @@ export default function CanvasEditorLegacyPage({
|
||||
setIsAddImageDialogOpen(true);
|
||||
}, [handleAddWorkflowNode, isVideoMode]);
|
||||
|
||||
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
|
||||
if (isVideoMode) return;
|
||||
|
||||
const slotDefaults: Record<
|
||||
AgentSlotNodeData["slotType"],
|
||||
{
|
||||
label: string;
|
||||
size: CanvasNode["size"];
|
||||
aspectRatio: string;
|
||||
}
|
||||
> = {
|
||||
image: {
|
||||
label: "图片槽位",
|
||||
size: { width: 320, height: 320 },
|
||||
aspectRatio: "1:1",
|
||||
},
|
||||
video: {
|
||||
label: "视频槽位",
|
||||
size: { width: 360, height: 220 },
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
"ui-screen": {
|
||||
label: "UI 槽位",
|
||||
size: { width: 360, height: 260 },
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
};
|
||||
const defaults = slotDefaults[slotType];
|
||||
const origin = getVisibleCanvasOrigin();
|
||||
createCanvasNode({
|
||||
type: "agent-slot",
|
||||
position: {
|
||||
x: origin.x + 40,
|
||||
y: origin.y + 40,
|
||||
},
|
||||
size: defaults.size,
|
||||
data: {
|
||||
slotType,
|
||||
label: defaults.label,
|
||||
status: "empty",
|
||||
aspectRatio: defaults.aspectRatio,
|
||||
fillPolicy: "replace-empty",
|
||||
acceptedSources: ["text", "image", "selection", "annotation"],
|
||||
filledNodeIds: [],
|
||||
metadata: {
|
||||
createdBy: "toolbar",
|
||||
createdSlotType: slotType,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
|
||||
|
||||
const handleAddAgentImageSlotNode = useCallback(() => {
|
||||
handleAddAgentSlotNode("image");
|
||||
}, [handleAddAgentSlotNode]);
|
||||
|
||||
const handleAddVideoNode = useCallback(() => {
|
||||
if (isVideoMode) {
|
||||
handleAddWorkflowNode("video-input");
|
||||
@ -10325,7 +10266,7 @@ export default function CanvasEditorLegacyPage({
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
data-canvas-stage="true"
|
||||
style={isVideoMode ? undefined : { right: panelOcclusionRight, top: 80 }}
|
||||
style={isVideoMode ? undefined : { right: panelOcclusionRight, top: 40 }}
|
||||
key={`workspace-${id}`}
|
||||
>
|
||||
<CanvasWorkspace
|
||||
@ -11080,7 +11021,7 @@ export default function CanvasEditorLegacyPage({
|
||||
)}
|
||||
data-canvas-right-panel="true"
|
||||
style={{
|
||||
top: 40,
|
||||
top: 0,
|
||||
right: DOCKED_PANEL_INSET,
|
||||
bottom: DOCKED_PANEL_INSET,
|
||||
width: resolvedPanelWidth,
|
||||
@ -11264,65 +11205,6 @@ export default function CanvasEditorLegacyPage({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!isVideoMode &&
|
||||
isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"addAgentImageSlot"
|
||||
) ? (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="h-9 w-9 rounded-xl" size="icon" variant="ghost">
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={ImagePlus}
|
||||
referenceName={isVideoMode ? undefined : "image"}
|
||||
referenceSize={18}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"addAgentImageSlot"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={floatingToolbarTooltipSide}>
|
||||
{
|
||||
getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"addAgentImageSlot"
|
||||
).label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side={floatingToolbarDropdownSide}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleAddAgentImageSlotNode()}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
图片槽位
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleAddAgentSlotNode("video")}
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
视频槽位
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleAddAgentSlotNode("ui-screen")}
|
||||
>
|
||||
<MonitorSmartphone className="h-4 w-4" />
|
||||
UI 槽位
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@ -592,7 +592,7 @@
|
||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-color: rgb(255 255 255 / 10%); }
|
||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] { border-color: rgb(255 255 255 / 12%); }
|
||||
@media (max-width: 640px) {
|
||||
.lovart-home-surface main { padding-top: 112px; }
|
||||
.lovart-home-surface main { padding-top: calc(72px + var(--home-banner-offset, 0px)); }
|
||||
.lovart-hero { margin-top: 16px; }
|
||||
.lovart-home-composer { border-radius: 20px; }
|
||||
.lovart-suggestion { padding: 0 11px; font-size: 12px; }
|
||||
@ -754,3 +754,25 @@
|
||||
.lovart-image-tool-tooltip { display: flex; align-items: center; gap: 8px; }
|
||||
.lovart-image-tool-cost { margin-left: auto; padding: 0; background: transparent; color: #999; font-size: 12px; font-weight: 400; box-shadow: none; }
|
||||
.lovart-image-tool-cost > svg { width: 12px; height: 12px; }
|
||||
|
||||
/* Preserve the canvas's own skills as the primary welcome content. */
|
||||
.canvas-welcome-action {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
}
|
||||
.canvas-welcome-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
|
||||
.canvas-welcome-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
|
||||
.dark .canvas-welcome-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
|
||||
.dark .canvas-welcome-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -21,6 +21,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE } from "@/lib/image-upload-limits";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import type {
|
||||
AgentWorkflowMaterialImage,
|
||||
AgentWorkflowMaterialQueryType,
|
||||
@ -163,6 +165,8 @@ export function CanvasAddImageDialog({
|
||||
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const localUploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
|
||||
const images = useMemo(() => normalizeImages(result), [result]);
|
||||
@ -182,12 +186,13 @@ export function CanvasAddImageDialog({
|
||||
|
||||
const handleDialogOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen && (isUploading || isAdding)) return;
|
||||
if (!nextOpen) {
|
||||
resetDialogState();
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
[onOpenChange, resetDialogState]
|
||||
[isAdding, isUploading, onOpenChange, resetDialogState]
|
||||
);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
@ -263,6 +268,51 @@ export function CanvasAddImageDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalUpload = async (files: File[]) => {
|
||||
if (!files.length || isUploading || isAdding) return;
|
||||
if (files.some((file) => !file.type.startsWith("image/"))) {
|
||||
setErrorText("请选择图片文件");
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => file.size > IMAGE_UPLOAD_MAX_BYTES)) {
|
||||
setErrorText(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setErrorText(null);
|
||||
try {
|
||||
const uploadedImages: CanvasAddImageDialogImage[] = [];
|
||||
const failures: string[] = [];
|
||||
for (const [index, file] of files.entries()) {
|
||||
const upload = await imageGenerationService.uploadImage(file);
|
||||
if (!upload.success || !upload.data?.url) {
|
||||
failures.push(`${file.name}:${upload.error || "图片上传失败"}`);
|
||||
continue;
|
||||
}
|
||||
uploadedImages.push({
|
||||
id: upload.data.assetId || crypto.randomUUID(),
|
||||
key: upload.data.pathname || file.name,
|
||||
label: file.name.replace(/\.[^.]+$/, "") || file.name,
|
||||
fileName: file.name,
|
||||
imageUrl: upload.data.url,
|
||||
assetId: upload.data.assetId,
|
||||
mimeType: file.type,
|
||||
sourceType: "upload",
|
||||
group: "other",
|
||||
sortOrder: index,
|
||||
});
|
||||
}
|
||||
if (uploadedImages.length) await onAddImages(uploadedImages);
|
||||
if (failures.length) setErrorText(failures.join(";"));
|
||||
else closeDialog();
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : "图片上传失败,请重试");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
||||
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
||||
@ -272,15 +322,38 @@ export function CanvasAddImageDialog({
|
||||
添加图片
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
|
||||
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
|
||||
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row">
|
||||
<input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
void handleLocalUpload(files);
|
||||
}}
|
||||
ref={localUploadInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
className="h-9 justify-start gap-2"
|
||||
className="h-9 shrink-0 justify-start gap-2"
|
||||
disabled={isUploading || isAdding}
|
||||
onClick={() => localUploadInputRef.current?.click()}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{isUploading ? "上传中…" : "本地上传"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-9 shrink-0 justify-start gap-2"
|
||||
disabled={isUploading || isAdding}
|
||||
onClick={() => {
|
||||
onCreateBlankImage();
|
||||
closeDialog();
|
||||
@ -355,13 +428,13 @@ export function CanvasAddImageDialog({
|
||||
</div>
|
||||
{!materialSearchEnabled ? (
|
||||
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
|
||||
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
|
||||
风格化功能或素材搜索步骤未开启,可从本地上传或添加空白图片。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorText ? (
|
||||
<div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
||||
<div role="alert" className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
@ -440,6 +513,7 @@ export function CanvasAddImageDialog({
|
||||
|
||||
<DialogFooter className="border-t px-5 py-3">
|
||||
<Button
|
||||
disabled={isUploading || isAdding}
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -448,7 +522,7 @@ export function CanvasAddImageDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!selectedImages.length || isAdding}
|
||||
disabled={!selectedImages.length || isAdding || isUploading}
|
||||
onClick={() => void handleAddSelected()}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@ -3325,7 +3325,7 @@ export function CanvasChatPanel({
|
||||
const [isComposerModeMenuOpen, setIsComposerModeMenuOpen] = useState(false);
|
||||
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
|
||||
const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false);
|
||||
const [isCanvasSkillPickerOpen, setIsCanvasSkillPickerOpen] = useState(false);
|
||||
const canvasSkillPickerRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSkillTagSearchQuery, setCanvasSkillTagSearchQuery] = useState("");
|
||||
const [selectedCanvasSkillCategoryId, setSelectedCanvasSkillCategoryId] =
|
||||
useState("all");
|
||||
@ -15881,19 +15881,26 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="lovart-chat-welcome flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-5 py-6">
|
||||
<div className="w-full max-w-[224px] text-center">
|
||||
<h1 className="mb-7 text-[16px] font-semibold leading-6 text-[#222222] dark:text-zinc-50" title={`${greetingUserName} ${greetingPeriodText} · 已生成 ${Number.isFinite(totalGeneratedImages) ? totalGeneratedImages : 0} 张图`}>准备怎么开启你的创作?</h1>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<button type="button" className="lovart-start-action" onClick={() => fileInputRef.current?.click()}><ReferenceIcon name="reference" size={16} className="text-[#a187ff]" />上传本地参考</button>
|
||||
{canvasSkillTags.length > 0 ? <button type="button" className="lovart-start-action" aria-expanded={isCanvasSkillPickerOpen} onClick={() => setIsCanvasSkillPickerOpen((open) => !open)}><ReferenceIcon name="skill-filled" size={16} className="text-[#657dff]" />选择 Skill 开始创作</button> : null}
|
||||
<button type="button" className="lovart-start-action" onClick={() => fileInputRef.current?.click()}><ReferenceIcon name="brand-filled" size={16} className="text-[#ee9b38]" />上传品牌素材</button>
|
||||
{onOpenFileList ? <button type="button" className="lovart-start-action" onClick={onOpenFileList}><ReferenceIcon name="brand" size={16} className="text-[#727272]" />查看画布素材</button> : null}
|
||||
</div>
|
||||
<div className="lovart-chat-welcome flex min-h-0 flex-1 flex-col overflow-y-auto px-5 pb-6 pt-6">
|
||||
<div className="shrink-0 text-left">
|
||||
<h1 className="text-[20px] font-semibold leading-tight text-[#050505] dark:text-zinc-50">
|
||||
Hi,{" "}
|
||||
{greetingUserName ? (
|
||||
<span className="text-[#2563ff]">{greetingUserName}</span>
|
||||
) : null}{" "}
|
||||
{greetingPeriodText}
|
||||
</h1>
|
||||
<p className="mt-2 text-[13px] font-normal leading-5 text-[#6b7280] dark:text-zinc-400">
|
||||
开始创作吧! 累计为你生成了{" "}
|
||||
<span className="font-semibold text-[#2563ff]">
|
||||
{Number.isFinite(totalGeneratedImages) ? totalGeneratedImages : 0}
|
||||
</span>{" "}
|
||||
张图。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canvasSkillTags.length > 0 && isCanvasSkillPickerOpen ? (
|
||||
<div className="mt-6 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
|
||||
{canvasSkillTags.length > 0 ? (
|
||||
<div ref={canvasSkillPickerRef} data-canvas-skill-picker="true" className="mt-6 w-full shrink-0 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
|
||||
<h3 className="text-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
|
||||
{canvasSkillTagsTitle}
|
||||
</h3>
|
||||
@ -15965,40 +15972,38 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canvasSkillTags.length > 4 ? (
|
||||
<div className="mx-auto mt-4 flex h-9 w-full max-w-[360px] items-center gap-2 rounded-lg border border-[#e5e7eb] bg-[#fafbfc] px-3 text-left transition-colors focus-within:border-[#aeb6c5] focus-within:bg-white dark:border-white/10 dark:bg-white/5 dark:focus-within:border-white/25 dark:focus-within:bg-white/[0.07]">
|
||||
<Search className="h-4 w-4 shrink-0 text-[#9299a6] dark:text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={canvasSkillTagSearchQuery}
|
||||
onChange={(event) =>
|
||||
setCanvasSkillTagSearchQuery(event.target.value)
|
||||
<div className="mx-auto mt-4 flex h-9 w-full max-w-[360px] items-center gap-2 rounded-lg border border-[#e5e7eb] bg-[#fafbfc] px-3 text-left transition-colors focus-within:border-[#aeb6c5] focus-within:bg-white dark:border-white/10 dark:bg-white/5 dark:focus-within:border-white/25 dark:focus-within:bg-white/[0.07]">
|
||||
<Search className="h-4 w-4 shrink-0 text-[#9299a6] dark:text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={canvasSkillTagSearchQuery}
|
||||
onChange={(event) =>
|
||||
setCanvasSkillTagSearchQuery(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setCanvasSkillTagSearchQuery("");
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setCanvasSkillTagSearchQuery("");
|
||||
}
|
||||
}}
|
||||
placeholder="搜索快捷技能"
|
||||
aria-label="搜索快捷技能"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
spellCheck={false}
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-[13px] text-[#1f2328] outline-none placeholder:text-[#9aa1ad] dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
{canvasSkillTagSearchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCanvasSkillTagSearchQuery("")}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center text-[#9299a6] transition-colors hover:text-[#1f2328] dark:text-zinc-500 dark:hover:text-zinc-100"
|
||||
title="清空搜索"
|
||||
aria-label="清空快捷技能搜索"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
}}
|
||||
placeholder="搜索快捷技能"
|
||||
aria-label="搜索快捷技能"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
spellCheck={false}
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-[13px] text-[#1f2328] outline-none placeholder:text-[#9aa1ad] dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
{canvasSkillTagSearchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCanvasSkillTagSearchQuery("")}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center text-[#9299a6] transition-colors hover:text-[#1f2328] dark:text-zinc-500 dark:hover:text-zinc-100"
|
||||
title="清空搜索"
|
||||
aria-label="清空快捷技能搜索"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex flex-wrap justify-center gap-1.5 transition-[max-height] duration-150 ease-out",
|
||||
@ -16007,7 +16012,7 @@ export function CanvasChatPanel({
|
||||
: "mt-6",
|
||||
isCanvasSkillTagSearching || areCanvasSkillTagsExpanded
|
||||
? "max-h-none overflow-visible"
|
||||
: "max-h-[76px] overflow-hidden"
|
||||
: "max-h-[calc(4rem+0.375rem)] overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{filteredCanvasSkillTags.map((tag) => {
|
||||
@ -16093,6 +16098,15 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-5 grid w-full shrink-0 grid-cols-2 gap-2">
|
||||
<button type="button" className="canvas-welcome-action" onClick={() => fileInputRef.current?.click()}><ReferenceIcon name="reference" size={16} className="text-[#a187ff]" />上传本地参考</button>
|
||||
{canvasSkillTags.length > 0 ? <button type="button" className="canvas-welcome-action" onClick={() => {
|
||||
canvasSkillPickerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
canvasSkillPickerRef.current?.querySelector("input")?.focus({ preventScroll: true });
|
||||
}}><ReferenceIcon name="skill-filled" size={16} className="text-[#657dff]" />选择 Skill 开始创作</button> : null}
|
||||
<button type="button" className="canvas-welcome-action" onClick={() => fileInputRef.current?.click()}><ReferenceIcon name="brand-filled" size={16} className="text-[#ee9b38]" />上传品牌素材</button>
|
||||
{onOpenFileList ? <button type="button" className="canvas-welcome-action" onClick={onOpenFileList}><ReferenceIcon name="brand" size={16} className="text-[#727272]" />查看画布素材</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -27,8 +27,12 @@ export function CanvasSurfaceHeader({
|
||||
rightInset,
|
||||
}: CanvasSurfaceHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-x-0 top-0 z-30 flex h-[40px] items-center border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950">
|
||||
<div
|
||||
className="absolute left-0 top-0 z-20 flex h-[40px] items-center justify-between border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950"
|
||||
data-canvas-topbar="true"
|
||||
style={{ right: rightInset }}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
aria-label="返回首页"
|
||||
className={iconButtonClass}
|
||||
@ -38,62 +42,54 @@ export function CanvasSurfaceHeader({
|
||||
>
|
||||
<ReferenceIcon name="branding" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="absolute left-0 top-[40px] z-20 flex h-[40px] items-center justify-between border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950"
|
||||
data-canvas-topbar="true"
|
||||
style={{ right: rightInset }}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
aria-label="添加参考素材"
|
||||
className={iconButtonClass}
|
||||
onClick={onAddImage}
|
||||
title="添加参考素材"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="reference" size={16} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="查看项目文件"
|
||||
className={iconButtonClass}
|
||||
onClick={onOpenFiles}
|
||||
title="查看项目文件"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="brand" size={16} />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-[#ededed] dark:bg-white/10" />
|
||||
<span className="inline-flex h-7 min-w-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[13px] text-[#333333] dark:bg-white/10 dark:text-white">
|
||||
<ReferenceIcon name="page" size={16} />
|
||||
<span className="max-w-[160px] truncate">
|
||||
{projectTitle || "页面 1"}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
aria-label="添加到画布"
|
||||
className={iconButtonClass}
|
||||
onClick={onAddImage}
|
||||
title="添加到画布"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="plus" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
aria-label="添加参考素材"
|
||||
className={iconButtonClass}
|
||||
onClick={onAddImage}
|
||||
title="添加参考素材"
|
||||
type="button"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
保存
|
||||
<ReferenceIcon name="reference" size={16} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="查看项目文件"
|
||||
className={iconButtonClass}
|
||||
onClick={onOpenFiles}
|
||||
title="查看项目文件"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="brand" size={16} />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-[#ededed] dark:bg-white/10" />
|
||||
<span className="inline-flex h-7 min-w-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[13px] text-[#333333] dark:bg-white/10 dark:text-white">
|
||||
<ReferenceIcon name="page" size={16} />
|
||||
<span className="max-w-[160px] truncate">
|
||||
{projectTitle || "页面 1"}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
aria-label="添加到画布"
|
||||
className={iconButtonClass}
|
||||
onClick={onAddImage}
|
||||
title="添加到画布"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="plus" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
<button
|
||||
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { ReferenceIcon, type ReferenceIconName } from "@/components/lovart/reference-icon";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
@ -71,7 +71,6 @@ import {
|
||||
normalizeLovartHomeSidebarConfig,
|
||||
type LovartHomeSidebarItemKey,
|
||||
} from "@/lib/home/lovart-sidebar-config";
|
||||
import { BrandTopBar } from "@/components/layout/brand-top-bar";
|
||||
import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-gallery";
|
||||
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
|
||||
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
|
||||
@ -422,16 +421,15 @@ function AccountMenuItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
<DropdownMenuItem
|
||||
onSelect={onClick}
|
||||
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-1.5 text-left text-[13px] font-medium text-black/80 hover:bg-black/5 dark:text-white/85 dark:hover:bg-white/6"
|
||||
>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-black/5 text-black/70 dark:bg-white/8 dark:text-white/70">
|
||||
<Icon className="h-[16px] w-[16px]" />
|
||||
</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1717,6 +1715,259 @@ export function LovartHome({
|
||||
THEME_MODE_OPTIONS[0];
|
||||
const SelectedThemeIcon = selectedThemeOption.icon;
|
||||
|
||||
const homeThemeControl = () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
title={`主题模式:${selectedThemeOption.label}`}
|
||||
aria-label={`主题模式:${selectedThemeOption.label}`}
|
||||
>
|
||||
<SelectedThemeIcon className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="z-[160] !min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{THEME_MODE_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
const isSelected = selectedThemeMode === option.value;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
aria-label={option.label}
|
||||
className={cn(
|
||||
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
|
||||
isSelected
|
||||
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
|
||||
: "text-black/70 dark:text-white/70"
|
||||
)}
|
||||
onSelect={() => setTheme(option.value)}
|
||||
title={option.label}
|
||||
>
|
||||
<OptionIcon className="h-[15px] w-[15px]" />
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
const homeUtilityControls = () => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="通知"
|
||||
title="通知"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10"
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{homeThemeControl()}
|
||||
</>
|
||||
);
|
||||
const homeAccountControls = () => (
|
||||
<div className="flex w-full flex-col items-center gap-[12px]">
|
||||
{isMembershipEnabled ? (
|
||||
<HoverCard openDelay={100} closeDelay={120}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
aria-label="积分与会员"
|
||||
className="h-auto w-[36px] rounded-[8px] px-0 py-2 text-[#333] hover:bg-black/5 dark:text-white/75 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-[4px] text-[10px] leading-none">
|
||||
<Zap className="h-[20px] w-[20px]" />
|
||||
{membershipData?.summary?.currentCredits ?? 0}
|
||||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="z-[150] w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-[22px] font-semibold leading-none text-black dark:text-white">
|
||||
{currentPlan?.name || "Free"}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2.5 border-t border-black/10 pt-3 text-[14px] dark:border-white/10 dark:text-white/90">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
积分
|
||||
</span>
|
||||
<span>{membershipData?.summary?.currentCredits ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
每日免费积分
|
||||
</span>
|
||||
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 inline-flex items-center gap-1 text-[12px] font-medium text-black/80 hover:text-black dark:text-white/75 dark:hover:text-white"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
使用详情
|
||||
<ChevronDown className="h-4 w-4 -rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : null}
|
||||
|
||||
{isAuthenticated ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[#d4551f] text-[11px] font-semibold text-white"
|
||||
aria-label="账号菜单"
|
||||
title="账号菜单"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="z-[150] max-h-[calc(100svh-24px)] w-[300px] overflow-y-auto rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="p-3.5">
|
||||
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
||||
{userInitials}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[15px] font-semibold text-black dark:text-white">
|
||||
{userDisplayName}
|
||||
</div>
|
||||
<div className="truncate text-[12px] text-black/45 dark:text-white/45">
|
||||
{user?.email || ""}
|
||||
</div>
|
||||
</div>
|
||||
{isMembershipEnabled ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
|
||||
<DropdownMenuItem asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5 dark:text-white/80 dark:hover:bg-white/6"
|
||||
onClick={() => {
|
||||
setAccountCenterTab("account");
|
||||
setUsageFilter("all");
|
||||
setAccountCenterOpen(true);
|
||||
}}
|
||||
>
|
||||
<span>积分</span>
|
||||
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60 dark:text-white/55">
|
||||
{membershipData?.summary?.currentCredits ?? 0}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{isMembershipEnabled ? (
|
||||
<AccountMenuItem
|
||||
icon={Users}
|
||||
label="创建团队"
|
||||
onClick={() => toast.info("团队功能后续开放")}
|
||||
/>
|
||||
) : null}
|
||||
<AccountMenuItem
|
||||
icon={User}
|
||||
label="账户管理"
|
||||
onClick={() => {
|
||||
setAccountCenterTab("account");
|
||||
setUsageFilter("all");
|
||||
setAccountCenterOpen(true);
|
||||
}}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={Info}
|
||||
label="使用指南"
|
||||
onClick={() => toast.info("使用指南后续开放")}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={Mail}
|
||||
label="联系我们"
|
||||
onClick={() => setContactDialogOpen(true)}
|
||||
/>
|
||||
<div className="flex items-center justify-between border-t border-black/10 px-2 py-2 dark:border-white/10">
|
||||
<span className="text-[13px] text-black/65 dark:text-white/65">
|
||||
通知与主题
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{homeUtilityControls()}
|
||||
</div>
|
||||
</div>
|
||||
<AccountMenuItem
|
||||
icon={LogOut}
|
||||
label="退出登录"
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{homeThemeControl()}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[32px] w-[36px] items-center justify-center rounded-full border border-black/15 bg-white text-[11px] font-medium text-black/80 hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
|
||||
onClick={openLoginDialog}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="lovart-home-surface min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
|
||||
{shouldShowHomeBanner ? (
|
||||
@ -1728,237 +1979,12 @@ export function LovartHome({
|
||||
text={homeBannerText}
|
||||
/>
|
||||
) : null}
|
||||
<BrandTopBar
|
||||
className="!h-[40px]"
|
||||
contentClassName="pl-[80px]"
|
||||
logo={displayLogo}
|
||||
offsetTop={0}
|
||||
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
showBrand={false}
|
||||
siteName={siteName || "Lovart"}
|
||||
>
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{isMembershipEnabled ? (
|
||||
<HoverCard openDelay={100} closeDelay={120}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
className="h-8 rounded-[14px] border border-black/10 bg-white px-2 text-[14px] text-black/72 shadow-sm hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1 rounded-[9px] px-1.5 py-0.5 text-xs">
|
||||
<Zap className="h-3 w-3 text-amber-500" />
|
||||
{membershipData?.summary?.currentCredits ?? 0}
|
||||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-[22px] font-semibold leading-none text-black dark:text-white">
|
||||
{currentPlan?.name || "Free"}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2.5 border-t border-black/10 pt-3 text-[14px] dark:border-white/10 dark:text-white/90">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
积分
|
||||
</span>
|
||||
<span>{membershipData?.summary?.currentCredits ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
每日免费积分
|
||||
</span>
|
||||
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 inline-flex items-center gap-1 text-[12px] font-medium text-black/80 hover:text-black dark:text-white/75 dark:hover:text-white"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
使用详情
|
||||
<ChevronDown className="h-4 w-4 -rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
title={`主题模式:${selectedThemeOption.label}`}
|
||||
aria-label={`主题模式:${selectedThemeOption.label}`}
|
||||
>
|
||||
<SelectedThemeIcon className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="!min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{THEME_MODE_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
const isSelected = selectedThemeMode === option.value;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
aria-label={option.label}
|
||||
className={cn(
|
||||
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
|
||||
isSelected
|
||||
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
|
||||
: "text-black/70 dark:text-white/70"
|
||||
)}
|
||||
onSelect={() => setTheme(option.value)}
|
||||
title={option.label}
|
||||
>
|
||||
<OptionIcon className="h-[15px] w-[15px]" />
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<HoverCard openDelay={80} closeDelay={120}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white"
|
||||
aria-label="Account menu"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="p-3.5">
|
||||
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
||||
{userInitials}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[15px] font-semibold text-black dark:text-white">
|
||||
{userDisplayName}
|
||||
</div>
|
||||
<div className="truncate text-[12px] text-black/45 dark:text-white/45">
|
||||
{user?.email || ""}
|
||||
</div>
|
||||
</div>
|
||||
{isMembershipEnabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5 dark:text-white/80 dark:hover:bg-white/6"
|
||||
onClick={() => {
|
||||
setAccountCenterTab("account");
|
||||
setUsageFilter("all");
|
||||
setAccountCenterOpen(true);
|
||||
}}
|
||||
>
|
||||
<span>积分</span>
|
||||
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60 dark:text-white/55">
|
||||
{membershipData?.summary?.currentCredits ?? 0}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{isMembershipEnabled ? (
|
||||
<AccountMenuItem
|
||||
icon={Users}
|
||||
label="创建团队"
|
||||
onClick={() => toast.info("团队功能后续开放")}
|
||||
/>
|
||||
) : null}
|
||||
<AccountMenuItem
|
||||
icon={User}
|
||||
label="账户管理"
|
||||
onClick={() => {
|
||||
setAccountCenterTab("account");
|
||||
setUsageFilter("all");
|
||||
setAccountCenterOpen(true);
|
||||
}}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={Info}
|
||||
label="使用指南"
|
||||
onClick={() => toast.info("使用指南后续开放")}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={Mail}
|
||||
label="联系我们"
|
||||
onClick={() => setContactDialogOpen(true)}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={LogOut}
|
||||
label="退出登录"
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
|
||||
onClick={openLoginDialog}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</BrandTopBar>
|
||||
<div className="fixed bottom-5 left-4 z-[100] flex w-[44px] items-center rounded-full bg-[#f5f5f5] py-2 shadow-sm lg:hidden dark:bg-[#141416]">
|
||||
{homeAccountControls()}
|
||||
</div>
|
||||
|
||||
<LovartSidebar
|
||||
accountControls={homeAccountControls()}
|
||||
agentEnabled={siteSettingsData?.agentEnabled !== false}
|
||||
getItem={getLovartSidebarItem}
|
||||
isEnabled={isLovartSidebarItemEnabled}
|
||||
@ -1985,7 +2011,7 @@ export function LovartHome({
|
||||
}
|
||||
siteLogo={displayLogo}
|
||||
siteName={siteName || "Lovart"}
|
||||
topOffset={40 + (shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0)}
|
||||
topOffset={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
|
||||
toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false}
|
||||
toolbox1Links={toolbox1Links}
|
||||
toolbox2Enabled={siteSettingsData?.toolbox2Enabled === true}
|
||||
@ -1997,9 +2023,12 @@ export function LovartHome({
|
||||
/>
|
||||
|
||||
<main
|
||||
style={
|
||||
{ "--home-banner-offset": `${shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}px` } as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"ml-[120px] mr-[60px] w-auto max-w-none pb-16 max-lg:mx-5",
|
||||
shouldShowHomeBanner ? "pt-[164px]" : "pt-[116px]"
|
||||
shouldShowHomeBanner ? "pt-[124px]" : "pt-[76px]"
|
||||
)}
|
||||
>
|
||||
{!isProjectsOnlyView && (
|
||||
@ -2153,7 +2182,7 @@ export function LovartHome({
|
||||
|
||||
{loginDialogOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
|
||||
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
|
||||
onClick={() => setLoginDialogOpen(false)}
|
||||
>
|
||||
<div
|
||||
|
||||
@ -20,7 +20,7 @@ export function LovartPromoBanner({
|
||||
const showAction = Boolean(link?.trim() && actionText?.trim());
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 top-[40px] z-[120] h-[48px] bg-[#e4ff58] text-[#11130d]">
|
||||
<div className="fixed inset-x-0 top-0 z-[120] h-[48px] bg-[#e4ff58] text-[#11130d]">
|
||||
<div className="mx-auto flex h-full max-w-[1440px] items-center justify-center gap-3 px-11 sm:px-12">
|
||||
<Sparkles className="hidden h-4 w-4 shrink-0 sm:block" />
|
||||
<p className="min-w-0 truncate text-center text-[13px] font-normal sm:text-[15px]">
|
||||
|
||||
@ -20,6 +20,7 @@ type SidebarItem = {
|
||||
};
|
||||
|
||||
type LovartSidebarProps = {
|
||||
accountControls?: ReactNode;
|
||||
agentEnabled: boolean;
|
||||
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
|
||||
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
|
||||
@ -56,6 +57,7 @@ type LovartSidebarProps = {
|
||||
};
|
||||
|
||||
export function LovartSidebar({
|
||||
accountControls,
|
||||
agentEnabled,
|
||||
getItem,
|
||||
isEnabled,
|
||||
@ -172,7 +174,7 @@ export function LovartSidebar({
|
||||
style={{ top: topOffset }}
|
||||
>
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto overflow-x-hidden px-2 pb-3 pt-[16px] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col items-center overflow-y-auto overflow-x-hidden px-2 pt-[16px] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@ -294,9 +296,10 @@ export function LovartSidebar({
|
||||
{toolbox("toolbox2", toolbox2Links, toolbox2Enabled)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex w-full flex-col items-center gap-1 pt-4">
|
||||
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 flex-col items-center gap-[10px] px-2 pb-[22px] pt-[12px]">
|
||||
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
|
||||
{accountControls}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</aside>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user