Files
image-prompt-studio/components/studio-client.tsx
2026-04-25 11:26:33 +08:00

704 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState } from "react";
import type {
GenerateMeta,
GeneratedImage,
PublicUser,
QuotaSnapshot,
} from "@/lib/types";
const QUICK_PROMPTS = [
"一款银白色耳机悬浮在纯白背景中,柔和边缘光,苹果官网产品摄影质感。",
"安静的极简客厅,浅木地板,大窗户自然光,室内设计杂志封面风格。",
"未来感城市夜景中的电动车,低饱和电影光影,写实概念海报。",
];
const PROCESSING_STAGES = [
"验证账号和额度",
"发送 Prompt 到 gpt-image-2",
"等待模型渲染图像",
"整理图片并准备下载",
];
type StudioStatus = "idle" | "pending" | "success" | "error";
type StudioResult = GeneratedImage & {
previewUrl: string;
};
type NotificationPermissionState = NotificationPermission | "unsupported";
type ServiceWorkerStatus = "checking" | "ready" | "error";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{
outcome: "accepted" | "dismissed";
platform: string;
}>;
}
interface GenerateResponse {
ok?: boolean;
error?: string;
images?: GeneratedImage[];
meta?: GenerateMeta;
quota?: QuotaSnapshot;
}
interface StudioClientProps {
currentUser: PublicUser;
initialQuota: QuotaSnapshot;
}
interface ProcessingCardProps {
status: StudioStatus;
startedAt: number;
}
interface PwaFeedback {
tone: "success" | "error";
message: string;
}
function getErrorMessage(error: unknown, fallbackMessage: string): string {
return error instanceof Error ? error.message : fallbackMessage;
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength - 1)}...`;
}
function getIsStandaloneMode(): boolean {
if (typeof window === "undefined") {
return false;
}
const iosStandalone = Boolean(
(window.navigator as Navigator & { standalone?: boolean }).standalone,
);
return window.matchMedia("(display-mode: standalone)").matches || iosStandalone;
}
async function showGenerationNotification({
prompt,
image,
meta,
}: {
prompt: string;
image: GeneratedImage;
meta: GenerateMeta | null;
}): Promise<void> {
if (typeof window === "undefined" || !("Notification" in window)) {
return;
}
if (Notification.permission !== "granted") {
return;
}
const details = [
meta?.model,
meta?.size,
meta?.durationMs ? `${meta.durationMs} ms` : null,
].filter(Boolean);
const body = [truncateText(prompt, 42), details.join(" · ")]
.filter(Boolean)
.join(" | ");
const options: NotificationOptions = {
body,
icon: "/pwa-icon.svg",
badge: "/pwa-badge.svg",
tag: `generation-${image.id}`,
data: {
url: "/studio",
},
};
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification("图片生成完成", options);
return;
}
const notification = new Notification("图片生成完成", options);
notification.onclick = () => {
window.focus();
window.location.href = "/studio";
};
}
export default function StudioClient({
currentUser,
initialQuota,
}: StudioClientProps) {
const [prompt, setPrompt] = useState("");
const [status, setStatus] = useState<StudioStatus>("idle");
const [errorMessage, setErrorMessage] = useState("");
const [requestMeta, setRequestMeta] = useState<GenerateMeta | null>(null);
const [result, setResult] = useState<StudioResult | null>(null);
const [quota, setQuota] = useState(initialQuota);
const [startedAt, setStartedAt] = useState(0);
const [serviceWorkerStatus, setServiceWorkerStatus] =
useState<ServiceWorkerStatus>("checking");
const [notificationPermission, setNotificationPermission] =
useState<NotificationPermissionState>("default");
const [installPromptEvent, setInstallPromptEvent] =
useState<BeforeInstallPromptEvent | null>(null);
const [isStandalone, setIsStandalone] = useState(false);
const [pwaFeedback, setPwaFeedback] = useState<PwaFeedback | null>(null);
useEffect(() => {
if (typeof window === "undefined") {
return undefined;
}
setNotificationPermission(
"Notification" in window ? Notification.permission : "unsupported",
);
setIsStandalone(getIsStandaloneMode());
const handleBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setInstallPromptEvent(event as BeforeInstallPromptEvent);
};
const handleAppInstalled = () => {
setInstallPromptEvent(null);
setIsStandalone(true);
setPwaFeedback({
tone: "success",
message: "应用已安装完成,之后可以直接从桌面或启动器打开。",
});
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("appinstalled", handleAppInstalled);
if (!("serviceWorker" in navigator)) {
setServiceWorkerStatus("error");
return () => {
window.removeEventListener(
"beforeinstallprompt",
handleBeforeInstallPrompt,
);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}
navigator.serviceWorker
.register("/sw.js")
.then(async () => {
await navigator.serviceWorker.ready;
setServiceWorkerStatus("ready");
})
.catch(() => {
setServiceWorkerStatus("error");
});
return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, []);
const promptStats = useMemo(() => {
const cleanPrompt = prompt.trim();
if (!cleanPrompt) {
return "输入一句尽可能具体的画面描述即可。";
}
return `${cleanPrompt.length} 个字符`;
}, [prompt]);
const serviceWorkerLabel = useMemo(() => {
if (serviceWorkerStatus === "ready") {
return "已就绪";
}
if (serviceWorkerStatus === "error") {
return "不可用";
}
return "注册中";
}, [serviceWorkerStatus]);
const notificationLabel = useMemo(() => {
if (notificationPermission === "granted") {
return "已开启";
}
if (notificationPermission === "denied") {
return "已拒绝";
}
if (notificationPermission === "unsupported") {
return "浏览器不支持";
}
return "等待授权";
}, [notificationPermission]);
const installLabel = useMemo(() => {
if (isStandalone) {
return "已作为应用运行";
}
if (installPromptEvent) {
return "可直接安装";
}
return "可通过浏览器菜单安装";
}, [installPromptEvent, isStandalone]);
async function handleInstallApp(): Promise<void> {
if (isStandalone) {
setPwaFeedback({
tone: "success",
message: "当前已经在 PWA 模式下运行。",
});
return;
}
if (!installPromptEvent) {
setPwaFeedback({
tone: "error",
message:
"当前浏览器没有暴露安装弹窗,可以使用地址栏或浏览器菜单中的“安装应用/添加到主屏幕”。",
});
return;
}
await installPromptEvent.prompt();
const choice = await installPromptEvent.userChoice;
setPwaFeedback({
tone: choice.outcome === "accepted" ? "success" : "error",
message:
choice.outcome === "accepted"
? "浏览器已接受安装请求,应用会加入桌面或启动器。"
: "这次没有完成安装,你之后仍然可以再次安装。",
});
setInstallPromptEvent(null);
}
async function handleEnableNotifications(): Promise<void> {
if (!("Notification" in window)) {
setNotificationPermission("unsupported");
setPwaFeedback({
tone: "error",
message: "当前浏览器不支持系统通知。",
});
return;
}
if (Notification.permission === "denied") {
setNotificationPermission("denied");
setPwaFeedback({
tone: "error",
message: "通知权限已被拒绝,请在浏览器的站点设置里手动重新开启。",
});
return;
}
const permission = await Notification.requestPermission();
setNotificationPermission(permission);
setPwaFeedback({
tone: permission === "granted" ? "success" : "error",
message:
permission === "granted"
? "系统通知已开启,图片生成成功后会立刻提醒你。"
: "你还没有授予通知权限,本次不会发送系统通知。",
});
}
async function handleGenerate(): Promise<void> {
const cleanPrompt = prompt.trim();
if (!cleanPrompt) {
setErrorMessage("请输入 Prompt 后再开始生成。");
return;
}
setStatus("pending");
setErrorMessage("");
setResult(null);
setRequestMeta(null);
setStartedAt(Date.now());
try {
const response = await fetch("/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: cleanPrompt,
size: "1024x1024",
quality: "high",
outputFormat: "png",
background: "auto",
count: 1,
moderation: "auto",
}),
});
const payload = (await response.json()) as GenerateResponse;
if (!response.ok || payload.ok !== true) {
throw new Error(payload.error || "生成失败,请稍后重试。");
}
const image = payload.images?.[0];
if (!image) {
throw new Error("没有拿到可用图片。");
}
setResult({
...image,
previewUrl: `data:${image.mimeType};base64,${image.base64}`,
});
setRequestMeta(payload.meta ?? null);
setQuota(payload.quota ?? initialQuota);
setStatus("success");
try {
await showGenerationNotification({
prompt: cleanPrompt,
image,
meta: payload.meta ?? null,
});
} catch {
setPwaFeedback({
tone: "error",
message: "图片已生成成功,但系统通知发送失败,请刷新后重试。",
});
}
} catch (error) {
setErrorMessage(getErrorMessage(error, "生成失败,请稍后重试。"));
setStatus("error");
}
}
function handleDownload() {
if (!result) {
return;
}
const blob = base64ToBlob(result.base64, result.mimeType);
const blobUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = blobUrl;
anchor.download = result.fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
return (
<div className="studio-grid">
<section className="card composer-card">
<div className="section-heading">
<div>
<p className="section-kicker">Prompt</p>
<h3>Describe your image</h3>
</div>
<span className="subtle-pill">
{quota.isUnlimited
? "Unlimited"
: `剩余 ${quota.remaining} / ${quota.limit}`}
</span>
</div>
<div className="pwa-panel">
<div>
<p className="section-kicker">PWA</p>
<h4></h4>
<p className="block-copy">
<code>/api/generate</code>{" "}
Service Worker
</p>
</div>
<div className="pwa-status-grid">
<div className="pwa-status-card">
<span></span>
<strong>{installLabel}</strong>
</div>
<div className="pwa-status-card">
<span></span>
<strong>{notificationLabel}</strong>
</div>
<div className="pwa-status-card">
<span>Service Worker</span>
<strong>{serviceWorkerLabel}</strong>
</div>
</div>
<div className="pwa-actions">
<button
type="button"
className="ghost-button"
onClick={handleInstallApp}
disabled={isStandalone}
>
{isStandalone ? "已安装" : "安装应用"}
</button>
<button
type="button"
className="ghost-button"
onClick={handleEnableNotifications}
disabled={notificationPermission === "granted"}
>
{notificationPermission === "granted"
? "通知已开启"
: "开启通知"}
</button>
</div>
{pwaFeedback ? (
<div
className={
pwaFeedback.tone === "success"
? "success-banner"
: "error-banner"
}
>
{pwaFeedback.message}
</div>
) : null}
</div>
<label className="field">
<span></span>
<textarea
rows={8}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="例如:一台银白色笔记本电脑放在浅灰色桌面上,清晨自然光从左侧落下,极简静物摄影,细节干净,像苹果官网产品页。"
/>
</label>
<div className="composer-meta">
<span>{promptStats}</span>
<span className="muted">
{currentUser.role === "admin"
? "管理员不会被扣减额度"
: `当前计费周期:${quota.periodKey}`}
</span>
</div>
<div className="quick-prompt-list">
{QUICK_PROMPTS.map((item) => (
<button
key={item}
type="button"
className="ghost-chip"
onClick={() => setPrompt(item)}
>
{item}
</button>
))}
</div>
{errorMessage ? (
<div className="error-banner">{errorMessage}</div>
) : null}
<div className="composer-actions">
<div className="meta-pills">
<span>Default size 1024</span>
<span>PNG</span>
<span>High quality</span>
</div>
<button
type="button"
className="primary-button"
onClick={handleGenerate}
disabled={status === "pending"}
>
{status === "pending" ? "生成中..." : "生成图片"}
</button>
</div>
</section>
<ProcessingCard key={startedAt} status={status} startedAt={startedAt} />
<section className="card result-card">
<div className="section-heading">
<div>
<p className="section-kicker">Result</p>
<h3></h3>
</div>
<button
type="button"
className="ghost-button"
disabled={!result}
onClick={handleDownload}
>
</button>
</div>
<p className="muted block-copy">
{requestMeta
? `${requestMeta.model} · ${requestMeta.size} · ${requestMeta.outputFormat.toUpperCase()} · ${requestMeta.durationMs} ms`
: "生成完成后会在这里显示耗时和图片。"}
</p>
{result ? (
<div className="result-stack">
<div className="image-frame">
<img
src={result.previewUrl}
alt={result.fileName}
width="1024"
height="1024"
/>
</div>
{result.revisedPrompt ? (
<div className="result-copy">
<span className="result-caption"> Prompt</span>
<p>{result.revisedPrompt}</p>
</div>
) : null}
</div>
) : (
<div className="result-placeholder">
<div className="placeholder-orb" />
<h4></h4>
<p></p>
</div>
)}
</section>
</div>
);
}
function ProcessingCard({ status, startedAt }: ProcessingCardProps) {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
if (status !== "pending") {
return undefined;
}
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 220);
return () => window.clearInterval(timer);
}, [status, startedAt]);
const progressSnapshot = useMemo(() => {
if (status === "success") {
return {
progress: 100,
activeIndex: PROCESSING_STAGES.length - 1,
lead: "图片已生成完成,可以直接查看和下载。",
};
}
if (status === "error") {
return {
progress: 0,
activeIndex: -1,
lead: "本次生成未完成,请检查 Prompt 或额度后重试。",
};
}
if (status !== "pending") {
return {
progress: 0,
activeIndex: -1,
lead: "准备就绪,输入 Prompt 后即可开始生成。",
};
}
const progress = Math.min(92, Math.round(elapsedMs / 250));
const activeIndex = Math.min(
PROCESSING_STAGES.length - 1,
Math.floor(elapsedMs / 5000),
);
return {
progress,
activeIndex,
lead: `处理中:${PROCESSING_STAGES[activeIndex]}`,
};
}, [elapsedMs, status]);
return (
<section className="card processing-card">
<div className="section-heading">
<div>
<p className="section-kicker">Processing</p>
<h3></h3>
</div>
<span className="subtle-pill">
{status === "pending"
? "进行中"
: status === "success"
? "完成"
: status === "error"
? "失败"
: "待命"}
</span>
</div>
<p className="block-copy">{progressSnapshot.lead}</p>
<div className="progress-track" aria-hidden="true">
<div
className="progress-fill"
style={{ width: `${progressSnapshot.progress}%` }}
/>
</div>
<ol className="processing-list">
{PROCESSING_STAGES.map((stage, index) => {
const isDone =
status === "success" || index < progressSnapshot.activeIndex;
const isActive =
status === "pending" && index === progressSnapshot.activeIndex;
return (
<li
key={stage}
className={
isDone
? "processing-item processing-item-done"
: isActive
? "processing-item processing-item-active"
: "processing-item"
}
>
<div className="step-dot" />
<span>{stage}</span>
</li>
);
})}
</ol>
</section>
);
}
function base64ToBlob(base64: string, mimeType: string): Blob {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType });
}