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 {
ChevronDown,
Clock3,
Download,
History as HistoryIcon,
ImageIcon,
Loader2,
@ -45,6 +46,7 @@ import {
type AiWashControlLevel,
type AiWashPreset,
} from "@/lib/ai-wash/config";
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
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: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record<string, number>;
@ -456,6 +463,9 @@ export function AiWashWorkspace({
useState<Record<string, number>>({});
const [historySourceOverride, setHistorySourceOverride] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [downloadingResultId, setDownloadingResultId] = useState<string | null>(
null
);
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false);
const [lastErrorMessage, setLastErrorMessage] = useState("");
const [comparePositionById, setComparePositionById] = useState<
@ -977,6 +987,30 @@ export function AiWashWorkspace({
});
}, [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 loadedPresetId =
(entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || "";
@ -1430,6 +1464,25 @@ export function AiWashWorkspace({
</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
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)}

View File

@ -1,6 +1,6 @@
"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 {
type CSSProperties,
@ -29,6 +29,7 @@ import {
SelectTrigger,
} from "@/components/ui/select";
import { imageGenerationService } from "@/services/canvas";
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
import { cn, fetcher } from "@/lib/utils";
import type {
PublicStyleProCategory,
@ -457,6 +458,15 @@ function formatStyleProHistoryDateTime(value?: string | null) {
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) {
const numeric = Number(estimatedWaitMs || 0);
if (!Number.isFinite(numeric) || numeric <= 0) {
@ -817,10 +827,16 @@ function GeneratingImagePlaceholder({
function BeforeAfterSlider({
afterUrl,
beforeUrl,
downloadDisabled,
downloading,
onDownload,
taskState,
}: {
afterUrl?: string | null;
beforeUrl?: string | null;
downloadDisabled?: boolean;
downloading?: boolean;
onDownload?: () => void;
taskState?: StyleProTaskState | null;
}) {
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>
{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>
);
}
@ -1021,6 +1055,9 @@ export function StyleProWorkspace({
useState<StyleProGenerationMode>("style");
const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false);
const [downloadingImageId, setDownloadingImageId] = useState<string | null>(
null
);
const [generationTaskState, setGenerationTaskState] =
useState<StyleProTaskState | null>(null);
const generating =
@ -1458,6 +1495,34 @@ export function StyleProWorkspace({
[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
? "请先上传图片"
: generationMode === "realistic" && config?.realisticModeAvailable === false
@ -1875,6 +1940,15 @@ export function StyleProWorkspace({
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
downloadDisabled={!currentResult?.resultImageUrl}
downloading={Boolean(
currentResult &&
downloadingImageId ===
(currentResult.id || currentResult.resultImageUrl)
)}
onDownload={() => {
void handleDownloadResult(currentResult);
}}
taskState={generationTaskState}
/>
</div>