feat: add downloads for style pro and ai wash results

This commit is contained in:
134 2026-07-07 14:12:18 +08:00
parent 0f27bf1b10
commit 77174f4e21
2 changed files with 128 additions and 1 deletions

View File

@ -3,6 +3,7 @@
import { import {
ChevronDown, ChevronDown,
Clock3, Clock3,
Download,
History as HistoryIcon, History as HistoryIcon,
ImageIcon, ImageIcon,
Loader2, Loader2,
@ -45,6 +46,7 @@ import {
type AiWashControlLevel, type AiWashControlLevel,
type AiWashPreset, type AiWashPreset,
} from "@/lib/ai-wash/config"; } from "@/lib/ai-wash/config";
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas"; import { imageGenerationService } from "@/services/canvas";
@ -368,6 +370,11 @@ function getAiWashStatusLabel(status?: AiWashResultStatus) {
} }
} }
function buildAiWashDownloadName(result: AiWashResultCard) {
const createdAt = result.createdAt || Date.now();
return `ai-wash-${result.title || result.presetId || "result"}-${createdAt}`;
}
function composePresetPrompt(params: { function composePresetPrompt(params: {
preset: AiWashPreset; preset: AiWashPreset;
selectedLevelIndexByControl: Record<string, number>; selectedLevelIndexByControl: Record<string, number>;
@ -456,6 +463,9 @@ export function AiWashWorkspace({
useState<Record<string, number>>({}); useState<Record<string, number>>({});
const [historySourceOverride, setHistorySourceOverride] = useState(""); const [historySourceOverride, setHistorySourceOverride] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [downloadingResultId, setDownloadingResultId] = useState<string | null>(
null
);
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false); const [isHistoryExpanded, setIsHistoryExpanded] = useState(false);
const [lastErrorMessage, setLastErrorMessage] = useState(""); const [lastErrorMessage, setLastErrorMessage] = useState("");
const [comparePositionById, setComparePositionById] = useState< const [comparePositionById, setComparePositionById] = useState<
@ -977,6 +987,30 @@ export function AiWashWorkspace({
}); });
}, [compareSourceUrl, onApplyResult, selectedResult]); }, [compareSourceUrl, onApplyResult, selectedResult]);
const handleDownloadSelectedResult = useCallback(async () => {
if (!selectedResult?.imageUrl) {
toast.error("请先选择一张洗图结果");
return;
}
const downloadId = selectedResult.presetId || selectedResult.imageUrl;
setDownloadingResultId(downloadId);
try {
await downloadCanvasImageAsPng({
url: selectedResult.imageUrl,
name: buildAiWashDownloadName(selectedResult),
candidates: [selectedResult.imageUrl],
});
toast.success("图片已开始下载");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
} finally {
setDownloadingResultId((current) =>
current === downloadId ? null : current
);
}
}, [selectedResult]);
const handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => { const handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => {
const loadedPresetId = const loadedPresetId =
(entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || ""; (entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || "";
@ -1430,6 +1464,25 @@ export function AiWashWorkspace({
</div> </div>
</div> </div>
{selectedResult?.imageUrl ? (
<button
className="absolute bottom-2 right-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 text-xs font-medium text-[#333333] shadow-[0_10px_24px_rgba(0,0,0,0.07)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111] disabled:cursor-not-allowed disabled:opacity-55"
disabled={Boolean(downloadingResultId)}
onClick={() => {
void handleDownloadSelectedResult();
}}
type="button"
>
{downloadingResultId ===
(selectedResult.presetId || selectedResult.imageUrl) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
下载
</button>
) : null}
<button <button
className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]" className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]"
onClick={() => setIsHistoryExpanded((current) => !current)} onClick={() => setIsHistoryExpanded((current) => !current)}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { ArrowLeft, Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react"; import { ArrowLeft, Clock3, Download, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
import { import {
type CSSProperties, type CSSProperties,
@ -29,6 +29,7 @@ import {
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { imageGenerationService } from "@/services/canvas"; import { imageGenerationService } from "@/services/canvas";
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import type { import type {
PublicStyleProCategory, PublicStyleProCategory,
@ -457,6 +458,15 @@ function formatStyleProHistoryDateTime(value?: string | null) {
return `${dateText} ${timeText}`; return `${dateText} ${timeText}`;
} }
function buildStyleProDownloadName(item: StyleProHistoryItem) {
const mode =
item.generationMode === "realistic" ? "style-pro-realistic" : "style-pro";
const createdAt = item.createdAt
? item.createdAt.replace(/[T:.]/g, "-").slice(0, 19)
: String(Date.now());
return `${mode}-${createdAt}`;
}
function formatStyleProEstimatedWait(estimatedWaitMs?: number | null) { function formatStyleProEstimatedWait(estimatedWaitMs?: number | null) {
const numeric = Number(estimatedWaitMs || 0); const numeric = Number(estimatedWaitMs || 0);
if (!Number.isFinite(numeric) || numeric <= 0) { if (!Number.isFinite(numeric) || numeric <= 0) {
@ -817,10 +827,16 @@ function GeneratingImagePlaceholder({
function BeforeAfterSlider({ function BeforeAfterSlider({
afterUrl, afterUrl,
beforeUrl, beforeUrl,
downloadDisabled,
downloading,
onDownload,
taskState, taskState,
}: { }: {
afterUrl?: string | null; afterUrl?: string | null;
beforeUrl?: string | null; beforeUrl?: string | null;
downloadDisabled?: boolean;
downloading?: boolean;
onDownload?: () => void;
taskState?: StyleProTaskState | null; taskState?: StyleProTaskState | null;
}) { }) {
const showTaskState = const showTaskState =
@ -946,6 +962,24 @@ function BeforeAfterSlider({
<div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black"> <div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black">
结果 结果
</div> </div>
{afterUrl ? (
<button
className="absolute bottom-4 right-4 z-20 inline-flex h-11 items-center gap-2 rounded-full border border-white/25 bg-black/72 px-4 text-[13px] font-semibold text-white shadow-[0_14px_32px_rgba(0,0,0,0.24)] backdrop-blur transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-55"
disabled={downloadDisabled || downloading}
onClick={(event) => {
event.stopPropagation();
onDownload?.();
}}
type="button"
>
{downloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
下载
</button>
) : null}
</div> </div>
); );
} }
@ -1021,6 +1055,9 @@ export function StyleProWorkspace({
useState<StyleProGenerationMode>("style"); useState<StyleProGenerationMode>("style");
const [selectedModelId, setSelectedModelId] = useState(""); const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [downloadingImageId, setDownloadingImageId] = useState<string | null>(
null
);
const [generationTaskState, setGenerationTaskState] = const [generationTaskState, setGenerationTaskState] =
useState<StyleProTaskState | null>(null); useState<StyleProTaskState | null>(null);
const generating = const generating =
@ -1458,6 +1495,34 @@ export function StyleProWorkspace({
[config] [config]
); );
const handleDownloadResult = useCallback(
async (item?: StyleProHistoryItem | null) => {
const target = item || currentResult;
if (!target?.resultImageUrl) {
toast.error("暂无可下载的结果图");
return;
}
const downloadId = target.id || target.resultImageUrl;
setDownloadingImageId(downloadId);
try {
await downloadCanvasImageAsPng({
url: target.resultImageUrl,
name: buildStyleProDownloadName(target),
candidates: [target.resultImageUrl],
});
toast.success("图片已开始下载");
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
} finally {
setDownloadingImageId((current) =>
current === downloadId ? null : current
);
}
},
[currentResult]
);
const disabledReason = !sourceImageUrl const disabledReason = !sourceImageUrl
? "请先上传图片" ? "请先上传图片"
: generationMode === "realistic" && config?.realisticModeAvailable === false : generationMode === "realistic" && config?.realisticModeAvailable === false
@ -1875,6 +1940,15 @@ export function StyleProWorkspace({
<BeforeAfterSlider <BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl} afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl} beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
downloadDisabled={!currentResult?.resultImageUrl}
downloading={Boolean(
currentResult &&
downloadingImageId ===
(currentResult.id || currentResult.resultImageUrl)
)}
onDownload={() => {
void handleDownloadResult(currentResult);
}}
taskState={generationTaskState} taskState={generationTaskState}
/> />
</div> </div>