From 9bf212f67ca3c570a7f2f023d90601c65afa5d28 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Sat, 25 Apr 2026 11:26:33 +0800 Subject: [PATCH] feat(project): init --- .dockerignore | 10 + .env.example | 14 + .gitignore | 13 + .prettierignore | 3 + .prettierrc.json | 5 + Dockerfile | 37 + README.md | 120 + app/admin/page.tsx | 25 + app/api/admin/settings/route.ts | 34 + app/api/admin/users/[id]/route.ts | 87 + app/api/admin/users/route.ts | 72 + app/api/auth/login/route.ts | 51 + app/api/auth/logout/route.ts | 22 + app/api/generate/route.ts | 115 + app/api/health/route.ts | 13 + app/api/images/[id]/route.ts | 53 + app/api/images/route.ts | 24 + app/globals.css | 926 +++++++ app/history/page.tsx | 32 + app/layout.tsx | 39 + app/login/page.tsx | 19 + app/manifest.ts | 45 + app/page.tsx | 8 + app/studio/page.tsx | 25 + components/admin-dashboard.tsx | 411 +++ components/history-gallery.tsx | 183 ++ components/login-form.tsx | 113 + components/logout-button.tsx | 36 + components/navigation-shell.tsx | 107 + components/studio-client.tsx | 703 +++++ docker-compose.yml | 12 + eslint.config.mjs | 68 + generated-image-1.png | Bin 0 -> 686896 bytes lib/admin.ts | 8 + lib/api-auth.ts | 39 + lib/auth.ts | 142 ++ lib/config.ts | 95 + lib/db.ts | 999 ++++++++ lib/http.ts | 29 + lib/image-storage.ts | 141 + lib/openai.ts | 130 + lib/quota-core.ts | 6 + lib/quota.ts | 101 + lib/request-schema.ts | 155 ++ lib/types.ts | 156 ++ next-env.d.ts | 6 + next.config.mjs | 7 + package-lock.json | 3970 +++++++++++++++++++++++++++++ package.json | 49 + public/app.js | 422 +++ public/index.html | 157 ++ public/prompt-builder.js | 111 + public/pwa-badge.svg | 5 + public/pwa-icon-maskable.svg | 20 + public/pwa-icon.svg | 19 + public/styles.css | 656 +++++ public/sw.js | 100 + scripts/load-env.mjs | 51 + scripts/reset-admin.mjs | 12 + scripts/start-standalone.mjs | 27 + tests/quota.test.ts | 9 + tests/request-schema.test.ts | 60 + tsconfig.json | 34 + 63 files changed, 11141 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/settings/route.ts create mode 100644 app/api/admin/users/[id]/route.ts create mode 100644 app/api/admin/users/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/generate/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/images/[id]/route.ts create mode 100644 app/api/images/route.ts create mode 100644 app/globals.css create mode 100644 app/history/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/manifest.ts create mode 100644 app/page.tsx create mode 100644 app/studio/page.tsx create mode 100644 components/admin-dashboard.tsx create mode 100644 components/history-gallery.tsx create mode 100644 components/login-form.tsx create mode 100644 components/logout-button.tsx create mode 100644 components/navigation-shell.tsx create mode 100644 components/studio-client.tsx create mode 100644 docker-compose.yml create mode 100644 eslint.config.mjs create mode 100644 generated-image-1.png create mode 100644 lib/admin.ts create mode 100644 lib/api-auth.ts create mode 100644 lib/auth.ts create mode 100644 lib/config.ts create mode 100644 lib/db.ts create mode 100644 lib/http.ts create mode 100644 lib/image-storage.ts create mode 100644 lib/openai.ts create mode 100644 lib/quota-core.ts create mode 100644 lib/quota.ts create mode 100644 lib/request-schema.ts create mode 100644 lib/types.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/prompt-builder.js create mode 100644 public/pwa-badge.svg create mode 100644 public/pwa-icon-maskable.svg create mode 100644 public/pwa-icon.svg create mode 100644 public/styles.css create mode 100644 public/sw.js create mode 100644 scripts/load-env.mjs create mode 100644 scripts/reset-admin.mjs create mode 100644 scripts/start-standalone.mjs create mode 100644 tests/quota.test.ts create mode 100644 tests/request-schema.test.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e15a9eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +node_modules +.next +npm-debug.log +.env +.env.local +data +tests +generated-image-1.png diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33a209a --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +OPENAI_API_KEY=sk-your-key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_IMAGE_MODEL=gpt-image-2 +OPENAI_TIMEOUT_MS=140000 + +DATABASE_PATH=./data/image-prompt-studio.db +GENERATED_IMAGES_PATH=./data/generated-images +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-now +ADMIN_SYNC_ON_BOOT=true +DEFAULT_MONTHLY_QUOTA=20 +SESSION_DAYS=30 + +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed69db6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.next/ +dist/ +target/ +coverage/ +.DS_Store +.env +.env.local +data/*.db +data/*.db-* +tsconfig.tsbuildinfo + +.codex \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fe9bc4f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +.next +coverage +node_modules diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..7229070 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..21f5647 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:24-bookworm-slim AS deps + +WORKDIR /app + +# configure registry mirror +RUN npm config set registry https://registry.npmmirror.com/ + +COPY package.json package-lock.json ./ +RUN npm install + +FROM node:24-bookworm-slim AS builder + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +RUN npm run build + +FROM node:24-bookworm-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +RUN mkdir -p /app/data + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || '3000') + '/api/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0856d3 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Image Prompt Studio + +一个基于 `Next.js + SQLite` 的图片生成应用,支持: + +- 用户登录 +- 管理员无限额 +- 普通用户额度控制 +- 管理页面设置默认额度和用户角色 +- `gpt-image-2` 图片生成 +- 图片落盘保存,并支持历史回看 +- PWA 安装支持 +- 图片生成成功后的系统通知 +- 苹果风格、同时兼顾性能的界面 + +## 技术栈 + +- Next.js App Router +- React +- SQLite +- `better-sqlite3` +- `bcryptjs` + +## 本地运行 + +1. 准备环境变量 + +```bash +cp .env.example .env +``` + +2. 填入真实 `OPENAI_API_KEY` + +3. 安装依赖并启动 + +```bash +npm install +npm run dev +``` + +4. 打开 + +```text +http://127.0.0.1:${PORT} +``` + +首次启动会自动创建 SQLite 文件,并按 `.env` 中的 `ADMIN_USERNAME` / `ADMIN_PASSWORD` 写入管理员账号。 +生成成功后的图片默认会保存在 `data/generated-images`,SQLite 只保存索引和元数据。 +默认情况下,`ADMIN_SYNC_ON_BOOT=true`,每次启动都会把 `.env` 中的管理员用户名和密码同步到数据库里的该管理员账号。 +`npm run dev` 会使用 Next.js 内建的环境变量加载;`npm start` 会读取项目根目录下的 `.env` / `.env.local`;`docker compose` 会使用 `env_file` 中指定的 `.env`。如果 shell 里已经设置了同名环境变量,则以 shell 为准。 +修改 `PORT`、`ADMIN_USERNAME`、`ADMIN_PASSWORD` 后,需要重启当前服务进程才会生效。 + +## PWA 与通知 + +- 应用已提供 `manifest.webmanifest`、Service Worker 和可安装图标资源。 +- 在支持 PWA 的浏览器中,进入“生成中心”后可以点击“安装应用”把站点安装成桌面应用。 +- 点击“开启通知”并授权后,当前页面调用 `/api/generate` 成功返回图片时,会触发系统通知。 +- 通知依赖浏览器的安全上下文,建议在 `localhost`、`127.0.0.1` 或 HTTPS 环境下测试。 +- 当前通知是“前端请求成功后触发”的模式,所以生成任务发起后页面需要保持打开;如果页面被关闭,请求本身也会终止。 + +## Docker + +```bash +docker compose up --build -d +``` + +默认映射到: + +```text +http://127.0.0.1:${PORT} +``` + +如果想换宿主机端口,可以这样: + +```bash +PORT=3876 docker compose up --build -d +``` + +如果你希望通过 `npm scripts` 调用 Docker Compose,而不是继续往 `package.json` 里加很多 `docker:*` 命令,可以使用这个通用入口: + +```bash +npm run dc -- up --build -d +``` + +常用示例: + +```bash +npm run dc -- ps +npm run dc -- logs -f image-prompt-studio +npm run dc -- down +npm run dc -- down --rmi local +npm run dc -- down --rmi all -v +``` + +## 管理员密码重置 + +如果你修改了 `.env` 中的 `ADMIN_USERNAME` / `ADMIN_PASSWORD`,可以直接重启服务。 + +如果你想手动立即同步一次,也可以运行: + +```bash +npm run admin:reset +``` + +## 默认规则 + +- `admin`:无限额 +- `user`:按月额度限制 +- 管理员可在后台修改默认月额度 +- 管理员可创建用户、改角色、改用户额度、禁用用户、重置密码 + +## 测试 + +```bash +npm test +``` + +## 备注 + +- 当前实现优先稳定和交付速度,不引入额外 ORM。 +- UI 保持苹果风格的浅色层级、圆角和留白,但去掉了大面积毛玻璃和重阴影,减少滚动卡顿。 diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..3051c1e --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,25 @@ +import NavigationShell from "@/components/navigation-shell"; +import AdminDashboard from "@/components/admin-dashboard"; +import { requireAdminUser, toPublicUser } from "@/lib/auth"; +import { getAdminSnapshot } from "@/lib/admin"; + +export default async function AdminPage() { + const user = await requireAdminUser(); + const publicUser = toPublicUser(user); + const snapshot = getAdminSnapshot(); + + if (!publicUser) { + throw new Error("Unable to resolve admin user."); + } + + return ( + + + + ); +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts new file mode 100644 index 0000000..6ff4763 --- /dev/null +++ b/app/api/admin/settings/route.ts @@ -0,0 +1,34 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { requireApiAdmin } from "@/lib/api-auth"; +import { setDefaultMonthlyQuota } from "@/lib/db"; +import { jsonError, normalizeError } from "@/lib/http"; + +export const runtime = "nodejs"; + +export async function PUT(request: NextRequest) { + try { + requireApiAdmin(request); + + const body = (await request.json()) as { + defaultMonthlyQuota?: unknown; + }; + const numericValue = Number.parseInt( + String(body?.defaultMonthlyQuota ?? ""), + 10, + ); + + if (!Number.isFinite(numericValue) || numericValue < 0) { + return jsonError("默认额度必须是大于等于 0 的整数。", 400); + } + + const settings = setDefaultMonthlyQuota(numericValue); + + return NextResponse.json({ + ok: true, + settings, + }); + } catch (error) { + const normalizedError = normalizeError(error, "保存默认额度失败。"); + return jsonError(normalizedError.message, normalizedError.statusCode); + } +} diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..3c5b9a5 --- /dev/null +++ b/app/api/admin/users/[id]/route.ts @@ -0,0 +1,87 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { requireApiAdmin } from "@/lib/api-auth"; +import { getUserById, updateUserRecord } from "@/lib/db"; +import { hashPassword, toPublicUser } from "@/lib/auth"; +import { getQuotaSnapshotForUser } from "@/lib/quota"; +import { jsonError, normalizeError } from "@/lib/http"; + +export const runtime = "nodejs"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const adminUser = requireApiAdmin(request); + const body = (await request.json()) as { + role?: unknown; + monthlyQuota?: unknown; + isActive?: unknown; + password?: unknown; + }; + const { id } = await params; + const targetUser = getUserById(id); + const monthlyQuota = + typeof body?.monthlyQuota === "number" || + typeof body?.monthlyQuota === "string" + ? body.monthlyQuota + : undefined; + const password = typeof body?.password === "string" ? body.password : ""; + + if (!targetUser) { + return jsonError("用户不存在。", 404); + } + + if (targetUser.id === adminUser.id) { + if (body?.role && body.role !== "admin") { + return jsonError("不能移除当前管理员自己的 admin 身份。", 400); + } + + if (body?.isActive === false) { + return jsonError("不能禁用当前管理员自己的账号。", 400); + } + } + + const updatedUser = updateUserRecord(targetUser.id, { + role: body?.role === "admin" ? "admin" : "user", + monthlyQuota: + body?.role === "admin" + ? null + : monthlyQuota === null || monthlyQuota === "" + ? null + : monthlyQuota, + isActive: + body?.isActive === undefined + ? targetUser.isActive + : Boolean(body.isActive), + passwordHash: + password.trim().length >= 8 ? hashPassword(password) : undefined, + }); + + if (!updatedUser) { + return jsonError("用户不存在。", 404); + } + + const publicUser = toPublicUser(updatedUser); + + if (!publicUser) { + return jsonError("保存用户失败。", 500); + } + + const quota = getQuotaSnapshotForUser(publicUser); + + return NextResponse.json({ + ok: true, + user: { + ...publicUser, + usedThisPeriod: quota.used, + remaining: quota.remaining, + limit: quota.limit, + isUnlimited: quota.isUnlimited, + }, + }); + } catch (error) { + const normalizedError = normalizeError(error, "保存用户失败。"); + return jsonError(normalizedError.message, normalizedError.statusCode); + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..831d693 --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,72 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { requireApiAdmin } from "@/lib/api-auth"; +import { createUserRecord, getUserByUsername } from "@/lib/db"; +import { hashPassword, toPublicUser } from "@/lib/auth"; +import { getQuotaSnapshotForUser } from "@/lib/quota"; +import { jsonError, normalizeError } from "@/lib/http"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + requireApiAdmin(request); + + const body = (await request.json()) as { + username?: unknown; + password?: unknown; + role?: unknown; + monthlyQuota?: unknown; + }; + const username = String(body?.username ?? "") + .trim() + .toLowerCase(); + const password = String(body?.password ?? ""); + const role = body?.role === "admin" ? "admin" : "user"; + const monthlyQuota = + typeof body?.monthlyQuota === "number" || + typeof body?.monthlyQuota === "string" + ? body.monthlyQuota + : undefined; + + if (!username) { + return jsonError("用户名不能为空。", 400); + } + + if (password.length < 8) { + return jsonError("密码至少需要 8 个字符。", 400); + } + + if (getUserByUsername(username)) { + return jsonError("该用户名已存在。", 409); + } + + const publicUser = toPublicUser( + createUserRecord({ + username, + passwordHash: hashPassword(password), + role, + monthlyQuota: role === "admin" ? null : monthlyQuota, + }), + ); + + if (!publicUser) { + return jsonError("创建用户失败。", 500); + } + + const quota = getQuotaSnapshotForUser(publicUser); + + return NextResponse.json({ + ok: true, + user: { + ...publicUser, + usedThisPeriod: quota.used, + remaining: quota.remaining, + limit: quota.limit, + isUnlimited: quota.isUnlimited, + }, + }); + } catch (error) { + const normalizedError = normalizeError(error, "创建用户失败。"); + return jsonError(normalizedError.message, normalizedError.statusCode); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..75d8203 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,51 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { + SESSION_COOKIE_NAME, + authenticateUser, + createSessionForUser, + getSessionCookieOptions, + toPublicUser, +} from "@/lib/auth"; +import { getQuotaSnapshotForUser } from "@/lib/quota"; +import { jsonError, normalizeError } from "@/lib/http"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as { + username?: unknown; + password?: unknown; + }; + const username = String(body?.username ?? "").trim(); + const password = String(body?.password ?? ""); + + if (!username || !password) { + return jsonError("用户名和密码不能为空。", 400); + } + + const user = authenticateUser(username, password); + + if (!user) { + return jsonError("用户名或密码错误。", 401); + } + + const session = createSessionForUser(user.id); + const response = NextResponse.json({ + ok: true, + user: toPublicUser(user), + quota: getQuotaSnapshotForUser(user), + }); + + response.cookies.set( + SESSION_COOKIE_NAME, + session.sessionId, + getSessionCookieOptions(session.expiresAt), + ); + + return response; + } catch (error) { + const normalizedError = normalizeError(error, "登录失败。"); + return jsonError(normalizedError.message, normalizedError.statusCode); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..bd6b691 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,22 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE_NAME, clearSessionById } from "@/lib/auth"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + const sessionId = request.cookies.get(SESSION_COOKIE_NAME)?.value; + + clearSessionById(sessionId); + + const response = NextResponse.json({ ok: true }); + + response.cookies.set(SESSION_COOKIE_NAME, "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + expires: new Date(0), + }); + + return response; +} diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 0000000..03c34cc --- /dev/null +++ b/app/api/generate/route.ts @@ -0,0 +1,115 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { randomUUID } from "node:crypto"; +import { requireApiUser } from "@/lib/api-auth"; +import { appConfig } from "@/lib/config"; +import { generateImagesWithOpenAI } from "@/lib/openai"; +import { parseGenerateRequest } from "@/lib/request-schema"; +import { assertCanGenerate, getQuotaSnapshotForUser } from "@/lib/quota"; +import { getCurrentPeriodKey } from "@/lib/quota-core"; +import { jsonError, normalizeError } from "@/lib/http"; +import { + recordCompletedGeneration, + recordGenerationJob, +} from "@/lib/db"; +import { + buildStoredImageUrl, + persistGeneratedImages, + removePersistedImageFiles, +} from "@/lib/image-storage"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + const user = requireApiUser(request); + let prompt = ""; + let payload: + | ReturnType + | null = null; + + try { + if (!appConfig.openAiApiKey) { + return jsonError("服务端尚未配置 OPENAI_API_KEY。", 500); + } + + assertCanGenerate(user); + + const body = (await request.json()) as unknown; + payload = parseGenerateRequest(body, appConfig.openAiImageModel); + prompt = payload.prompt; + + const result = await generateImagesWithOpenAI(payload); + const jobId = randomUUID(); + const persistedImages = await persistGeneratedImages({ + userId: user.id, + jobId, + images: result.images, + }); + + try { + recordCompletedGeneration({ + jobId, + userId: user.id, + prompt, + imageCount: result.images.length, + durationMs: result.meta.durationMs, + model: result.meta.model, + size: result.meta.size, + quality: result.meta.quality, + outputFormat: result.meta.outputFormat, + background: result.meta.background, + moderation: result.meta.moderation, + outputCompression: payload.output_compression ?? null, + images: persistedImages.images, + usageMeta: { + model: result.meta.model, + size: result.meta.size, + quality: result.meta.quality, + outputFormat: result.meta.outputFormat, + background: result.meta.background, + }, + periodKey: getCurrentPeriodKey(), + }); + } catch (error) { + await removePersistedImageFiles(persistedImages.files); + throw error; + } + + const images = result.images.map((image, index) => ({ + ...image, + id: persistedImages.images[index]?.id ?? image.id, + fileName: persistedImages.images[index]?.fileName ?? image.fileName, + url: persistedImages.images[index] + ? buildStoredImageUrl(persistedImages.images[index].id) + : undefined, + })); + const nextQuota = getQuotaSnapshotForUser(user); + + return NextResponse.json({ + ok: true, + images, + meta: result.meta, + quota: nextQuota, + }); + } catch (error) { + if (prompt) { + recordGenerationJob({ + userId: user.id, + prompt, + status: "error", + model: payload?.model ?? null, + size: payload?.size ?? null, + quality: payload?.quality ?? null, + outputFormat: payload?.output_format ?? null, + background: payload?.background ?? null, + moderation: payload?.moderation ?? null, + outputCompression: payload?.output_compression ?? null, + errorMessage: error instanceof Error ? error.message : "unknown error", + }); + } + + const normalizedError = normalizeError(error, "图片生成失败。"); + return jsonError(normalizedError.message, normalizedError.statusCode, { + detail: normalizedError.detail, + }); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..6171cd2 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { appConfig } from "@/lib/config"; + +export const runtime = "nodejs"; + +export async function GET() { + return NextResponse.json({ + ok: true, + configured: Boolean(appConfig.openAiApiKey), + model: appConfig.openAiImageModel, + timeoutMs: appConfig.openAiTimeoutMs, + }); +} diff --git a/app/api/images/[id]/route.ts b/app/api/images/[id]/route.ts new file mode 100644 index 0000000..0dd507c --- /dev/null +++ b/app/api/images/[id]/route.ts @@ -0,0 +1,53 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { requireApiUser } from "@/lib/api-auth"; +import { getStoredImageAssetForViewer } from "@/lib/db"; +import { jsonError, normalizeError } from "@/lib/http"; +import { readStoredImageFile } from "@/lib/image-storage"; + +export const runtime = "nodejs"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const viewer = requireApiUser(request); + const { id } = await params; + const asset = getStoredImageAssetForViewer({ + viewerId: viewer.id, + viewerRole: viewer.role, + imageId: id, + }); + + if (!asset) { + return jsonError("图片不存在。", 404); + } + + const fileBuffer = await readStoredImageFile(asset.storageKey); + const shouldDownload = request.nextUrl.searchParams.get("download") === "1"; + const dispositionType = shouldDownload ? "attachment" : "inline"; + + return new NextResponse(new Uint8Array(fileBuffer), { + status: 200, + headers: { + "Content-Type": asset.mimeType, + "Content-Length": String(fileBuffer.byteLength), + "Cache-Control": "private, max-age=86400", + "Content-Disposition": `${dispositionType}; filename*=UTF-8''${encodeURIComponent(asset.fileName)}`, + }, + }); + } catch (error) { + const normalizedError = normalizeError(error, "读取图片失败。"); + + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "ENOENT" + ) { + return jsonError("图片不存在。", 404); + } + + return jsonError(normalizedError.message, normalizedError.statusCode); + } +} diff --git a/app/api/images/route.ts b/app/api/images/route.ts new file mode 100644 index 0000000..388f462 --- /dev/null +++ b/app/api/images/route.ts @@ -0,0 +1,24 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { requireApiUser } from "@/lib/api-auth"; +import { listImageHistoryForViewer } from "@/lib/db"; +import { jsonError, normalizeError } from "@/lib/http"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + try { + const viewer = requireApiUser(request); + const items = listImageHistoryForViewer({ + viewerId: viewer.id, + viewerRole: viewer.role, + }); + + return NextResponse.json({ + ok: true, + items, + }); + } catch (error) { + const normalizedError = normalizeError(error, "读取图片历史失败。"); + return jsonError(normalizedError.message, normalizedError.statusCode); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..bcb3905 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,926 @@ +:root { + color-scheme: light; + font-family: + -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", + "Segoe UI", sans-serif; + --bg: #f5f5f7; + --bg-accent: #eef2f7; + --surface: #ffffff; + --surface-muted: #f7f8fb; + --surface-strong: #eef3fb; + --line-soft: rgba(15, 23, 42, 0.08); + --line-strong: rgba(15, 23, 42, 0.12); + --ink-0: #101114; + --ink-1: #30343b; + --ink-2: #69707d; + --accent: #0071e3; + --accent-soft: rgba(0, 113, 227, 0.1); + --success: #2eb872; + --danger: #d92d20; + --shadow-lg: 0 24px 56px rgba(15, 23, 42, 0.07); + --shadow-sm: 0 12px 28px rgba(15, 23, 42, 0.05); + background: + radial-gradient( + circle at top left, + rgba(0, 113, 227, 0.08), + transparent 25% + ), + radial-gradient( + circle at top right, + rgba(255, 55, 95, 0.08), + transparent 24% + ), + linear-gradient(180deg, #fafbfc 0%, #f4f5f7 100%); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + color: var(--ink-0); + background: transparent; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +input, +textarea, +select { + width: 100%; + border-radius: 20px; + border: 1px solid var(--line-soft); + background: #fff; + color: var(--ink-0); + padding: 0.95rem 1rem; + outline: none; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +input:focus, +textarea:focus, +select:focus { + border-color: rgba(0, 113, 227, 0.22); + box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.08); +} + +textarea { + resize: vertical; + min-height: 240px; +} + +.eyebrow, +.section-kicker, +.sidebar-label { + margin: 0; + color: var(--ink-2); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px 16px; +} + +.login-panel, +.card, +.page-hero, +.dashboard-sidebar { + border-radius: 30px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow-lg); +} + +.login-panel { + width: min(100%, 560px); + padding: 28px; +} + +.login-form { + display: grid; + gap: 18px; +} + +.login-copy h1 { + margin: 6px 0 10px; + font-size: clamp(2rem, 5vw, 3.2rem); + line-height: 0.96; + letter-spacing: -0.04em; +} + +.login-copy p:last-child { + margin: 0; + color: var(--ink-2); + line-height: 1.6; +} + +.field { + display: grid; + gap: 10px; +} + +.field span { + color: var(--ink-1); + font-size: 0.95rem; + font-weight: 600; +} + +.dashboard-shell { + width: min(1480px, calc(100% - 32px)); + margin: 0 auto; + padding: 22px 0 40px; + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 22px; +} + +.dashboard-sidebar { + position: sticky; + top: 22px; + height: fit-content; + padding: 22px; + display: grid; + gap: 18px; +} + +.brand-lockup { + display: flex; + align-items: center; + gap: 14px; +} + +.brand-mark { + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, #0a84ff 0%, #5ac8fa 46%, #ff375f 100%); + box-shadow: 0 10px 18px rgba(10, 132, 255, 0.18); +} + +.brand-lockup h1, +.page-hero h2, +.section-heading h3 { + margin: 4px 0 0; + letter-spacing: -0.03em; +} + +.brand-lockup h1 { + font-size: 1.16rem; +} + +.sidebar-card { + padding: 18px; + border-radius: 24px; + background: var(--surface-muted); + border: 1px solid var(--line-soft); +} + +.sidebar-card strong { + display: block; + margin-top: 8px; +} + +.sidebar-copy { + margin: 10px 0 0; + color: var(--ink-2); + line-height: 1.55; +} + +.sidebar-nav { + display: grid; + gap: 10px; +} + +.sidebar-link { + display: grid; + gap: 4px; + padding: 14px 16px; + border-radius: 20px; + border: 1px solid transparent; + color: var(--ink-1); + background: transparent; + transition: + transform 140ms ease, + border-color 140ms ease, + background 140ms ease; +} + +.sidebar-link:hover, +.sidebar-link-active { + background: var(--surface-muted); + border-color: var(--line-soft); + transform: translateY(-1px); +} + +.sidebar-link strong { + font-size: 0.98rem; +} + +.sidebar-link span { + color: var(--ink-2); + font-size: 0.88rem; +} + +.sidebar-actions { + margin-top: 6px; +} + +.dashboard-main { + display: grid; + gap: 22px; +} + +.page-hero { + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(220px, 0.8fr); + gap: 20px; + padding: 26px; +} + +.page-hero h2 { + font-size: clamp(2rem, 4vw, 3.2rem); + line-height: 0.96; + max-width: 12ch; +} + +.hero-copy, +.block-copy { + margin: 12px 0 0; + color: var(--ink-1); + line-height: 1.65; +} + +.hero-orbs { + position: relative; + min-height: 220px; +} + +.hero-orb { + position: absolute; + border-radius: 50%; +} + +.hero-orb-blue { + left: 16px; + top: 20px; + width: 160px; + height: 160px; + background: radial-gradient( + circle, + rgba(90, 200, 250, 0.9), + rgba(0, 113, 227, 0.06) 72% + ); +} + +.hero-orb-pink { + right: 18px; + bottom: 10px; + width: 180px; + height: 180px; + background: radial-gradient( + circle, + rgba(255, 55, 95, 0.38), + rgba(255, 55, 95, 0) 72% + ); +} + +.hero-orb-silver { + left: 78px; + top: 70px; + width: 158px; + height: 158px; + background: radial-gradient( + circle at 30% 30%, + rgba(255, 255, 255, 1), + rgba(220, 225, 232, 0.7) 56%, + rgba(255, 255, 255, 0.15) 100% + ); +} + +.page-content, +.studio-grid, +.admin-grid, +.history-grid, +.history-overview-grid, +.history-gallery-list { + display: grid; + gap: 22px; +} + +.studio-grid { + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.8fr); +} + +.admin-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.history-overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.card { + padding: 24px; + content-visibility: auto; +} + +.composer-card { + display: grid; + gap: 18px; +} + +.result-card, +.user-table-card { + grid-column: 1 / -1; +} + +.section-heading { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 18px; +} + +.section-heading h3 { + font-size: 1.46rem; +} + +.composer-card .section-heading { + margin-bottom: 0; +} + +.subtle-pill, +.meta-pills span { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0.45rem 0.85rem; + border-radius: 999px; + background: var(--accent-soft); + border: 1px solid rgba(0, 113, 227, 0.12); + color: #0b5cab; + font-size: 0.92rem; +} + +.composer-meta, +.composer-actions { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; +} + +.composer-meta { + margin-top: 14px; + color: var(--ink-2); +} + +.quick-prompt-list, +.meta-pills, +.form-grid { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.quick-prompt-list { + margin-top: 18px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 14px; +} + +.composer-actions { + margin-top: 20px; +} + +.pwa-panel { + display: grid; + gap: 16px; + padding: 18px; + border-radius: 24px; + border: 1px solid rgba(0, 113, 227, 0.12); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, #f6f9fe 100%), + radial-gradient(circle at top right, rgba(90, 200, 250, 0.18), transparent 40%); +} + +.pwa-panel h4 { + margin: 4px 0 0; + font-size: 1.16rem; + letter-spacing: -0.02em; +} + +.pwa-panel .block-copy { + margin-top: 10px; +} + +.pwa-status-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.pwa-status-card { + padding: 14px 16px; + border-radius: 20px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.8); +} + +.pwa-status-card span { + display: block; + color: var(--ink-2); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.pwa-status-card strong { + display: block; + margin-top: 8px; + color: var(--ink-0); + line-height: 1.45; +} + +.pwa-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.primary-button, +.ghost-button, +.ghost-chip { + min-height: 44px; + border-radius: 999px; + transition: + transform 140ms ease, + opacity 140ms ease, + background 140ms ease; +} + +.primary-button { + border: none; + padding: 0.88rem 1.3rem; + background: linear-gradient(180deg, #1482f0 0%, #0071e3 100%); + color: #fff; + box-shadow: 0 14px 30px rgba(0, 113, 227, 0.2); + font-weight: 700; +} + +.ghost-button, +.ghost-chip { + border: 1px solid var(--line-soft); + background: var(--surface-muted); + color: var(--ink-1); +} + +.ghost-button { + padding: 0.82rem 1.1rem; +} + +.ghost-chip { + padding: 0.8rem 1rem; + text-align: left; +} + +.primary-button:hover, +.ghost-button:hover, +.ghost-chip:hover { + transform: translateY(-1px); +} + +.primary-button:disabled, +.ghost-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.muted { + color: var(--ink-2); +} + +.progress-track { + margin-top: 18px; + height: 10px; + border-radius: 999px; + background: #e8ecf3; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #5ac8fa 0%, #0071e3 100%); + transition: width 180ms linear; +} + +.processing-list { + list-style: none; + margin: 20px 0 0; + padding: 0; + display: grid; + gap: 12px; +} + +.processing-item { + display: flex; + align-items: center; + gap: 12px; + color: var(--ink-2); +} + +.processing-item-done, +.processing-item-active { + color: var(--ink-1); +} + +.step-dot { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid #c7d0dd; + background: #fff; +} + +.processing-item-done .step-dot { + background: var(--success); + border-color: transparent; +} + +.processing-item-active .step-dot { + background: var(--accent); + border-color: transparent; + box-shadow: 0 0 0 6px rgba(0, 113, 227, 0.1); +} + +.image-frame { + overflow: hidden; + border-radius: 24px; + background: linear-gradient(180deg, #eff3f8 0%, #f7f9fb 100%); +} + +.image-frame img { + display: block; + width: 100%; + height: auto; + aspect-ratio: 1 / 1; + object-fit: cover; +} + +.result-stack { + display: grid; + gap: 16px; +} + +.result-copy { + padding: 16px 18px; + border-radius: 22px; + background: var(--surface-muted); + border: 1px solid var(--line-soft); +} + +.result-caption { + color: var(--ink-2); + font-size: 0.88rem; +} + +.result-copy p { + margin: 8px 0 0; + color: var(--ink-1); + line-height: 1.65; + white-space: pre-wrap; +} + +.result-placeholder { + min-height: 460px; + display: grid; + place-items: center; + text-align: center; +} + +.placeholder-orb { + width: 160px; + height: 160px; + border-radius: 32px; + background: + radial-gradient( + circle at 30% 30%, + rgba(90, 200, 250, 0.95), + rgba(0, 113, 227, 0.06) 72% + ), + linear-gradient(180deg, #ffffff 0%, #eef2f7 100%); +} + +.result-placeholder h4 { + margin: 18px 0 10px; + font-size: 1.12rem; +} + +.result-placeholder p { + margin: 0; + color: var(--ink-2); + max-width: 34ch; + line-height: 1.6; +} + +.history-overview-card-muted { + background: linear-gradient(180deg, #fbfcfe 0%, #f6f8fb 100%); +} + +.history-stat-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.history-stat-card, +.history-meta-list div { + padding: 16px; + border-radius: 22px; + border: 1px solid var(--line-soft); + background: var(--surface-muted); +} + +.history-stat-card span, +.history-meta-list span { + display: block; + color: var(--ink-2); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.history-stat-card strong, +.history-meta-list strong { + display: block; + margin-top: 8px; + font-size: 1.08rem; + color: var(--ink-0); + line-height: 1.35; +} + +.history-info-pills { + margin-top: 20px; +} + +.history-empty-card { + min-height: 480px; + display: grid; + place-items: center; + text-align: center; +} + +.history-empty-card h4 { + margin: 18px 0 10px; + font-size: 1.18rem; +} + +.history-empty-card p { + margin: 0; + max-width: 34ch; + color: var(--ink-2); + line-height: 1.65; +} + +.history-gallery-list { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.history-card { + padding: 18px; + display: grid; + gap: 16px; +} + +.history-card-media { + overflow: hidden; + border-radius: 24px; + aspect-ratio: 1 / 1; + background: linear-gradient(180deg, #eff3f8 0%, #f7f9fb 100%); +} + +.history-card-media img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.history-card-header { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start; +} + +.history-card-header h4 { + margin: 0; + font-size: 1.04rem; + line-height: 1.35; + word-break: break-word; +} + +.history-card-header p { + margin: 8px 0 0; +} + +.history-owner-pill { + white-space: nowrap; +} + +.history-meta-pills { + gap: 8px; +} + +.history-meta-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.history-disclosure { + padding: 16px 18px; + border-radius: 22px; + border: 1px solid var(--line-soft); + background: var(--surface-muted); +} + +.history-disclosure summary { + cursor: pointer; + color: var(--ink-1); + font-weight: 700; + list-style: none; +} + +.history-disclosure summary::-webkit-details-marker { + display: none; +} + +.history-disclosure p { + margin: 12px 0 0; + color: var(--ink-1); + line-height: 1.68; + white-space: pre-wrap; +} + +.history-actions { + display: flex; + gap: 12px; +} + +.history-actions a { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.history-download-button { + display: inline-flex; + align-items: center; +} + +.success-banner, +.error-banner { + padding: 14px 16px; + border-radius: 20px; + border: 1px solid var(--line-soft); +} + +.success-banner { + background: rgba(46, 184, 114, 0.09); + color: #0c7b44; +} + +.error-banner { + background: rgba(217, 45, 32, 0.08); + color: var(--danger); +} + +.user-table { + display: grid; + gap: 12px; +} + +.user-table-head, +.user-table-row { + display: grid; + grid-template-columns: 1.2fr 0.9fr 0.9fr 0.8fr 0.8fr 1fr 0.7fr; + gap: 12px; + align-items: center; +} + +.user-table-head { + padding: 0 8px; + color: var(--ink-2); + font-size: 0.86rem; +} + +.user-table-row { + padding: 12px; + border-radius: 22px; + background: var(--surface-muted); + border: 1px solid var(--line-soft); +} + +.user-cell-name { + display: grid; +} + +.user-cell-name strong { + font-size: 0.98rem; +} + +.user-cell-name small { + color: var(--ink-2); +} + +@media (max-width: 1180px) { + .dashboard-shell, + .studio-grid, + .admin-grid, + .history-overview-grid, + .page-hero { + grid-template-columns: 1fr; + } + + .dashboard-sidebar { + position: static; + } + + .page-hero h2 { + max-width: none; + } +} + +@media (max-width: 880px) { + .dashboard-shell { + width: min(100% - 20px, 100%); + } + + .section-heading, + .composer-meta, + .composer-actions, + .history-card-header, + .history-actions { + flex-direction: column; + align-items: flex-start; + } + + .form-grid, + .pwa-status-grid, + .history-stat-grid, + .history-meta-list, + .user-table-head, + .user-table-row { + grid-template-columns: 1fr; + } + + .user-table-head { + display: none; + } + + .history-actions a { + width: 100%; + } +} diff --git a/app/history/page.tsx b/app/history/page.tsx new file mode 100644 index 0000000..83d1674 --- /dev/null +++ b/app/history/page.tsx @@ -0,0 +1,32 @@ +import HistoryGallery from "@/components/history-gallery"; +import NavigationShell from "@/components/navigation-shell"; +import { requireCurrentUser, toPublicUser } from "@/lib/auth"; +import { listImageHistoryForViewer } from "@/lib/db"; + +export default async function HistoryPage() { + const user = await requireCurrentUser(); + const publicUser = toPublicUser(user); + const items = listImageHistoryForViewer({ + viewerId: user.id, + viewerRole: user.role, + }); + + if (!publicUser) { + throw new Error("Unable to resolve current user."); + } + + return ( + + + + ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..a7d936d --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,39 @@ +import type { Metadata, Viewport } from "next"; +import type { ReactNode } from "react"; +import "@/app/globals.css"; + +const applicationName = "Image Prompt Studio"; + +export const metadata: Metadata = { + title: applicationName, + description: "Next.js + SQLite powered image generation workspace", + applicationName, + manifest: "/manifest.webmanifest", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: applicationName, + }, + formatDetection: { + telephone: false, + }, + icons: { + icon: [{ url: "/pwa-icon.svg", type: "image/svg+xml" }], + apple: [{ url: "/pwa-icon.svg", type: "image/svg+xml" }], + shortcut: ["/pwa-icon.svg"], + }, +}; + +export const viewport: Viewport = { + themeColor: "#f5f5f7", +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..3e51212 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import LoginForm from "@/components/login-form"; +import { getCachedCurrentUser } from "@/lib/auth"; + +export default async function LoginPage() { + const user = await getCachedCurrentUser(); + + if (user) { + redirect(user.role === "admin" ? "/admin" : "/studio"); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..289f9d2 --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,45 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Image Prompt Studio", + short_name: "Prompt Studio", + description: "生成图片、保存历史记录,并在生成成功后发送系统通知。", + start_url: "/studio", + scope: "/", + display: "standalone", + background_color: "#f5f5f7", + theme_color: "#f5f5f7", + lang: "zh-CN", + orientation: "portrait", + categories: ["graphics", "productivity"], + icons: [ + { + src: "/pwa-icon.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "any", + }, + { + src: "/pwa-icon-maskable.svg", + sizes: "any", + type: "image/svg+xml", + purpose: "maskable", + }, + ], + shortcuts: [ + { + name: "打开生成中心", + short_name: "生成中心", + description: "进入图片生成工作区", + url: "/studio", + }, + { + name: "查看历史记录", + short_name: "历史记录", + description: "查看最近生成过的图片", + url: "/history", + }, + ], + }; +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..663520b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; +import { getCachedCurrentUser } from "@/lib/auth"; + +export default async function HomePage() { + const user = await getCachedCurrentUser(); + + redirect(user ? "/studio" : "/login"); +} diff --git a/app/studio/page.tsx b/app/studio/page.tsx new file mode 100644 index 0000000..875607b --- /dev/null +++ b/app/studio/page.tsx @@ -0,0 +1,25 @@ +import NavigationShell from "@/components/navigation-shell"; +import StudioClient from "@/components/studio-client"; +import { requireCurrentUser, toPublicUser } from "@/lib/auth"; +import { getQuotaSnapshotForUser } from "@/lib/quota"; + +export default async function StudioPage() { + const user = await requireCurrentUser(); + const publicUser = toPublicUser(user); + const quota = getQuotaSnapshotForUser(user); + + if (!publicUser) { + throw new Error("Unable to resolve current user."); + } + + return ( + + + + ); +} diff --git a/components/admin-dashboard.tsx b/components/admin-dashboard.tsx new file mode 100644 index 0000000..83a5bad --- /dev/null +++ b/components/admin-dashboard.tsx @@ -0,0 +1,411 @@ +"use client"; + +import { useState } from "react"; +import type { + AdminSnapshot, + AdminUserSummary, + SettingsSnapshot, + UserRole, +} from "@/lib/types"; + +interface SettingsResponse { + ok?: boolean; + error?: string; + settings?: SettingsSnapshot; +} + +interface UserResponse { + ok?: boolean; + error?: string; + user?: AdminUserSummary; +} + +interface SettingsFormState { + defaultMonthlyQuota: string; +} + +interface CreateUserDraft { + username: string; + password: string; + role: UserRole; + monthlyQuota: string; +} + +type EditableAdminUser = Omit & { + monthlyQuota: string | null; + passwordDraft: string; +}; + +function toEditableUser(user: AdminUserSummary): EditableAdminUser { + return { + ...user, + monthlyQuota: user.monthlyQuota === null ? null : String(user.monthlyQuota), + passwordDraft: "", + }; +} + +function getErrorMessage(error: unknown, fallbackMessage: string): string { + return error instanceof Error ? error.message : fallbackMessage; +} + +export default function AdminDashboard({ + initialSnapshot, +}: { + initialSnapshot: AdminSnapshot; +}) { + const [settings, setSettings] = useState({ + defaultMonthlyQuota: String(initialSnapshot.settings.defaultMonthlyQuota), + }); + const [users, setUsers] = useState( + initialSnapshot.users.map(toEditableUser), + ); + const [feedback, setFeedback] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isSavingSettings, setIsSavingSettings] = useState(false); + const [createDraft, setCreateDraft] = useState({ + username: "", + password: "", + role: "user", + monthlyQuota: settings.defaultMonthlyQuota, + }); + + async function saveSettings(): Promise { + setFeedback(""); + setErrorMessage(""); + setIsSavingSettings(true); + + try { + const response = await fetch("/api/admin/settings", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + defaultMonthlyQuota: settings.defaultMonthlyQuota, + }), + }); + const payload = (await response.json()) as SettingsResponse; + + if (!response.ok || payload.ok !== true || !payload.settings) { + throw new Error(payload.error || "保存默认额度失败。"); + } + + setSettings({ + defaultMonthlyQuota: String(payload.settings.defaultMonthlyQuota), + }); + setFeedback("默认额度已更新。"); + } catch (error) { + setErrorMessage(getErrorMessage(error, "保存默认额度失败。")); + } finally { + setIsSavingSettings(false); + } + } + + async function createUser(): Promise { + setFeedback(""); + setErrorMessage(""); + + try { + const response = await fetch("/api/admin/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(createDraft), + }); + const payload = (await response.json()) as UserResponse; + + if (!response.ok || payload.ok !== true || !payload.user) { + throw new Error(payload.error || "创建用户失败。"); + } + + const createdUser = payload.user; + + setUsers((currentUsers) => [ + ...currentUsers, + toEditableUser(createdUser), + ]); + setCreateDraft({ + username: "", + password: "", + role: "user", + monthlyQuota: settings.defaultMonthlyQuota, + }); + setFeedback("用户已创建。"); + } catch (error) { + setErrorMessage(getErrorMessage(error, "创建用户失败。")); + } + } + + async function saveUser(user: EditableAdminUser): Promise { + setFeedback(""); + setErrorMessage(""); + + try { + const response = await fetch(`/api/admin/users/${user.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + role: user.role, + monthlyQuota: user.role === "admin" ? null : user.monthlyQuota, + isActive: user.isActive, + password: user.passwordDraft || undefined, + }), + }); + const payload = (await response.json()) as UserResponse; + + if (!response.ok || payload.ok !== true || !payload.user) { + throw new Error(payload.error || "保存用户失败。"); + } + + const savedUser = payload.user; + + setUsers((currentUsers) => + currentUsers.map((item) => + item.id === user.id ? toEditableUser(savedUser) : item, + ), + ); + setFeedback(`已保存 ${savedUser.username}。`); + } catch (error) { + setErrorMessage(getErrorMessage(error, "保存用户失败。")); + } + } + + return ( +
+
+
+
+

Policy

+

默认额度设置

+
+ {initialSnapshot.periodKey} +
+ + + +

+ 没有单独指定额度的普通用户,将继承这里的月额度。 +

+ + +
+ +
+
+
+

Users

+

创建用户

+
+
+ +
+ + + + + + + +
+ + +
+ + {(feedback || errorMessage) && ( +
+ {errorMessage || feedback} +
+ )} + +
+
+
+

Manage

+

用户管理

+
+
+ +
+
+ 用户 + 角色 + 额度 + 本月已用 + 状态 + 重置密码 + 操作 +
+ + {users.map((user) => ( +
+
+ {user.username} + + {user.isUnlimited ? "无限额" : `剩余 ${user.remaining}`} + +
+ + + + + setUsers((currentUsers) => + currentUsers.map((item) => + item.id === user.id + ? { + ...item, + monthlyQuota: + event.target.value === "" + ? null + : event.target.value, + } + : item, + ), + ) + } + /> + + {user.usedThisPeriod} + + + + + setUsers((currentUsers) => + currentUsers.map((item) => + item.id === user.id + ? { ...item, passwordDraft: event.target.value } + : item, + ), + ) + } + /> + + +
+ ))} +
+
+
+ ); +} diff --git a/components/history-gallery.tsx b/components/history-gallery.tsx new file mode 100644 index 0000000..c89160f --- /dev/null +++ b/components/history-gallery.tsx @@ -0,0 +1,183 @@ +import type { ImageHistoryItem, PublicUser } from "@/lib/types"; + +interface HistoryGalleryProps { + currentUser: PublicUser; + items: ImageHistoryItem[]; +} + +const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", +}); + +export default function HistoryGallery({ + currentUser, + items, +}: HistoryGalleryProps) { + const totalUsers = new Set(items.map((item) => item.userId)).size; + const latestItem = items[0] ?? null; + + return ( +
+
+
+
+
+

Archive

+

+ {currentUser.role === "admin" ? "全站生成历史" : "我的生成历史"} +

+
+ {items.length} 张图片 +
+ +
+
+ 图片总数 + {items.length} +
+
+ {currentUser.role === "admin" ? "涉及用户" : "当前身份"} + + {currentUser.role === "admin" ? `${totalUsers} 位` : "普通用户"} + +
+
+ 最近一张 + + {latestItem + ? dateTimeFormatter.format(new Date(latestItem.createdAt)) + : "暂无记录"} + +
+
+
+ +
+
+
+

Access

+

权限与查看范围

+
+
+ +

+ {currentUser.role === "admin" + ? "管理员可以查看所有用户的历史记录、原始提示词和生成参数,页面会额外标注每张图所属的账号。" + : "普通用户只能查看自己的历史记录和图片内容,接口会按当前登录账号做隔离。"} +

+ +
+ Prompt 可回看 + 按账号隔离 + 原图可下载 +
+
+
+ + {items.length === 0 ? ( +
+
+

还没有历史图片

+

+ 生成成功后的图片会自动保存到服务器,这里会展示图片、提示词和模型参数。 +

+
+ ) : ( +
+ {items.map((item) => ( +
+
+ {item.fileName} +
+ +
+
+

{item.fileName}

+

+ {dateTimeFormatter.format(new Date(item.createdAt))} +

+
+ {currentUser.role === "admin" ? ( + + {item.username} + + ) : null} +
+ +
+ {item.model ?? "未知模型"} + {item.size ?? "未知尺寸"} + {item.quality ?? "未知质量"} +
+ +
+
+ 格式 + {formatOutputFormatLabel(item)} +
+
+ 背景 + {item.background ?? "auto"} +
+
+ 文件大小 + {formatFileSize(item.fileSize)} +
+
+ +
+ 查看提示词 +

{item.prompt}

+
+ + {item.revisedPrompt ? ( +
+ 查看模型调整后的提示词 +

{item.revisedPrompt}

+
+ ) : null} + + +
+ ))} +
+ )} +
+ ); +} + +function formatFileSize(fileSize: number): string { + if (fileSize < 1024) { + return `${fileSize} B`; + } + + if (fileSize < 1024 * 1024) { + return `${(fileSize / 1024).toFixed(1)} KB`; + } + + return `${(fileSize / (1024 * 1024)).toFixed(2)} MB`; +} + +function formatOutputFormatLabel(item: ImageHistoryItem): string { + if (item.outputFormat) { + return item.outputFormat.toUpperCase(); + } + + return item.mimeType.replace(/^image\//, "").toUpperCase(); +} diff --git a/components/login-form.tsx b/components/login-form.tsx new file mode 100644 index 0000000..cce27f2 --- /dev/null +++ b/components/login-form.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { startTransition, useMemo, useState, type FormEvent } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import type { UserRole } from "@/lib/types"; + +interface LoginResponse { + ok?: boolean; + error?: string; + user?: { + role: UserRole; + }; +} + +function getErrorMessage(error: unknown, fallbackMessage: string): string { + return error instanceof Error ? error.message : fallbackMessage; +} + +export default function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isPending, setIsPending] = useState(false); + + const notice = useMemo(() => { + const reason = searchParams.get("reason"); + + if (reason === "inactive") { + return "当前账号不可用,请联系管理员。"; + } + + return "使用管理员或已创建的普通用户登录。"; + }, [searchParams]); + + async function handleSubmit( + event: FormEvent, + ): Promise { + event.preventDefault(); + setErrorMessage(""); + setIsPending(true); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username, + password, + }), + }); + const payload = (await response.json()) as LoginResponse; + + if (!response.ok || payload.ok !== true || !payload.user) { + throw new Error(payload.error || "登录失败,请稍后重试。"); + } + + const currentUser = payload.user; + + startTransition(() => { + router.push(currentUser.role === "admin" ? "/admin" : "/studio"); + router.refresh(); + }); + } catch (error) { + setErrorMessage(getErrorMessage(error, "登录失败,请稍后重试。")); + } finally { + setIsPending(false); + } + } + + return ( +
+
+

Secure Access

+

登录后才能使用图片生成与后台管理。

+

{notice}

+
+ + + + + + {errorMessage ?
{errorMessage}
: null} + + +
+ ); +} diff --git a/components/logout-button.tsx b/components/logout-button.tsx new file mode 100644 index 0000000..df20097 --- /dev/null +++ b/components/logout-button.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState, startTransition } from "react"; +import { useRouter } from "next/navigation"; + +export default function LogoutButton() { + const router = useRouter(); + const [isPending, setIsPending] = useState(false); + + async function handleLogout(): Promise { + setIsPending(true); + + try { + await fetch("/api/auth/logout", { + method: "POST", + }); + } finally { + startTransition(() => { + router.push("/login"); + router.refresh(); + }); + setIsPending(false); + } + } + + return ( + + ); +} diff --git a/components/navigation-shell.tsx b/components/navigation-shell.tsx new file mode 100644 index 0000000..0db63be --- /dev/null +++ b/components/navigation-shell.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import LogoutButton from "@/components/logout-button"; +import type { PublicUser } from "@/lib/types"; + +interface NavigationShellProps { + currentUser: PublicUser; + activePath: string; + title: string; + description: string; + children: ReactNode; +} + +export default function NavigationShell({ + currentUser, + activePath, + title, + description, + children, +}: NavigationShellProps) { + const navigationItems = [ + { + href: "/studio", + label: "生成中心", + description: "输入 Prompt,生成并下载图片", + }, + { + href: "/history", + label: "历史记录", + description: + currentUser.role === "admin" + ? "查看所有用户生成过的图片" + : "回看自己曾经生成的图片", + }, + ...(currentUser.role === "admin" + ? [ + { + href: "/admin", + label: "管理后台", + description: "用户、角色和额度配置", + }, + ] + : []), + ]; + + return ( +
+ + +
+
+
+

Workspace

+

{title}

+

{description}

+
+
+ +
{children}
+
+
+ ); +} diff --git a/components/studio-client.tsx b/components/studio-client.tsx new file mode 100644 index 0000000..40fae14 --- /dev/null +++ b/components/studio-client.tsx @@ -0,0 +1,703 @@ +"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; + 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 { + 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("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const [requestMeta, setRequestMeta] = useState(null); + const [result, setResult] = useState(null); + const [quota, setQuota] = useState(initialQuota); + const [startedAt, setStartedAt] = useState(0); + const [serviceWorkerStatus, setServiceWorkerStatus] = + useState("checking"); + const [notificationPermission, setNotificationPermission] = + useState("default"); + const [installPromptEvent, setInstallPromptEvent] = + useState(null); + const [isStandalone, setIsStandalone] = useState(false); + const [pwaFeedback, setPwaFeedback] = useState(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 { + 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 { + 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 { + 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 ( +
+
+
+
+

Prompt

+

Describe your image

+
+ + {quota.isUnlimited + ? "Unlimited" + : `剩余 ${quota.remaining} / ${quota.limit}`} + +
+ +
+
+

PWA

+

安装应用并接收生成完成通知

+

+ 允许通知后,当前页面调用 /api/generate{" "} + 成功返回图片时,会通过 Service Worker 发送系统提醒。 +

+
+ +
+
+ 应用状态 + {installLabel} +
+
+ 通知权限 + {notificationLabel} +
+
+ Service Worker + {serviceWorkerLabel} +
+
+ +
+ + +
+ + {pwaFeedback ? ( +
+ {pwaFeedback.message} +
+ ) : null} +
+ +
+ +
+
+
+
+

Processing

+

等待过程

+
+ 待命 +
+ +

+ 准备就绪,输入 Prompt 后即可开始生成。 +

+ + + +
    +
    + +
    +
    +
    +

    Result

    +

    生成结果

    +
    + +
    + + +

    + 生成完成后会在这里显示耗时和输出信息。 +

    +
    +
    +
    + +
    + + + + diff --git a/public/prompt-builder.js b/public/prompt-builder.js new file mode 100644 index 0000000..d9a887e --- /dev/null +++ b/public/prompt-builder.js @@ -0,0 +1,111 @@ +export const STORAGE_KEY = "image-prompt-studio:simple-prompt:v1"; + +export const GENERATION_DEFAULTS = { + size: "1024x1024", + quality: "high", + outputFormat: "png", + background: "auto", + count: 1, + moderation: "auto", + outputCompression: 90, +}; + +export const EXAMPLE_PROMPTS = [ + "一只白色陶瓷杯放在浅灰色窗边桌面上,晨光柔和,极简产品摄影,干净背景,苹果官网质感。", + "未来感电动车停在雾气很轻的海边公路,银色车身,阴天柔光,电影级写实海报。", + "一间现代风格客厅,浅木地板,奶油白沙发,大面积自然光,室内设计杂志封面感。", + "一款无线耳机悬浮在纯白背景中,蓝色柔和反射光,商业广告风,细节锐利。", +]; + +export const PROCESSING_STAGES = [ + { + title: "整理 Prompt", + detail: "检查内容并准备发送到服务端。", + thresholdMs: 1400, + }, + { + title: "连接 gpt-image-2", + detail: "向 OpenAI Images API 发起图片生成请求。", + thresholdMs: 5200, + }, + { + title: "等待渲染", + detail: "模型正在生成高质量图片,这一步通常最耗时。", + thresholdMs: 15000, + }, + { + title: "准备下载", + detail: "接收 base64 图片并整理成浏览器可下载文件。", + thresholdMs: 24000, + }, +]; + +export function sanitizePrompt(input) { + return String(input ?? "") + .replace(/\r/g, "") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .split("\n") + .map((line) => line.trim()) + .join("\n") + .trim(); +} + +export function buildPromptStats(prompt) { + const cleanPrompt = sanitizePrompt(prompt); + + if (!cleanPrompt) { + return "输入一句清晰的画面描述即可开始。"; + } + + const lineCount = cleanPrompt.split("\n").length; + const charCount = cleanPrompt.length; + + return `${charCount} 个字符 · ${lineCount} 行`; +} + +export function buildDownloadStem(prompt) { + const normalized = sanitizePrompt(prompt) + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .slice(0, 48); + + return normalized || "generated-image"; +} + +export function createProcessingSnapshot(elapsedMs, isComplete = false) { + if (isComplete) { + return { + progress: 1, + activeStageIndex: PROCESSING_STAGES.length - 1, + lead: "图片已生成完成,可以预览和下载。", + }; + } + + const safeElapsed = Math.max(0, elapsedMs); + const finalThreshold = + PROCESSING_STAGES[PROCESSING_STAGES.length - 1]?.thresholdMs ?? 24000; + const progress = Math.min(0.92, safeElapsed / finalThreshold); + const activeStageIndex = resolveActiveStageIndex(safeElapsed); + const activeStage = PROCESSING_STAGES[activeStageIndex]; + + return { + progress, + activeStageIndex, + lead: activeStage + ? `${activeStage.title}中... ${activeStage.detail}` + : "正在处理,请稍候。", + }; +} + +function resolveActiveStageIndex(elapsedMs) { + for (let index = PROCESSING_STAGES.length - 1; index >= 0; index -= 1) { + if (elapsedMs >= PROCESSING_STAGES[index].thresholdMs) { + return index; + } + } + + return 0; +} diff --git a/public/pwa-badge.svg b/public/pwa-badge.svg new file mode 100644 index 0000000..31812c0 --- /dev/null +++ b/public/pwa-badge.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/pwa-icon-maskable.svg b/public/pwa-icon-maskable.svg new file mode 100644 index 0000000..af005fd --- /dev/null +++ b/public/pwa-icon-maskable.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/pwa-icon.svg b/public/pwa-icon.svg new file mode 100644 index 0000000..b13b453 --- /dev/null +++ b/public/pwa-icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..6adc69a --- /dev/null +++ b/public/styles.css @@ -0,0 +1,656 @@ +:root { + color-scheme: light; + font-family: + -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", + "Segoe UI", sans-serif; + --bg: #f5f5f7; + --surface: rgba(255, 255, 255, 0.78); + --surface-strong: #ffffff; + --surface-muted: #f0f1f5; + --line-soft: rgba(15, 23, 42, 0.08); + --line-strong: rgba(15, 23, 42, 0.12); + --ink-0: #111111; + --ink-1: #3b3b3f; + --ink-2: #6c7078; + --accent: #0071e3; + --accent-soft: rgba(0, 113, 227, 0.12); + --success: #34c759; + --danger: #ff3b30; + --shadow-lg: 0 24px 80px rgba(16, 24, 40, 0.08); + --shadow-sm: 0 12px 36px rgba(16, 24, 40, 0.05); + background: + radial-gradient( + circle at top left, + rgba(0, 113, 227, 0.11), + transparent 26% + ), + radial-gradient( + circle at top right, + rgba(255, 45, 85, 0.1), + transparent 24% + ), + linear-gradient(180deg, #fafafc 0%, #f4f5f8 100%); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + min-width: 320px; + color: var(--ink-0); + background: transparent; +} + +button, +textarea, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +textarea { + width: 100%; + resize: vertical; + min-height: 220px; + border: 1px solid var(--line-soft); + border-radius: 26px; + padding: 22px 24px; + background: rgba(255, 255, 255, 0.74); + color: var(--ink-0); + outline: none; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); + transition: + border-color 160ms ease, + box-shadow 160ms ease, + background 160ms ease; +} + +textarea:focus { + border-color: rgba(0, 113, 227, 0.26); + background: rgba(255, 255, 255, 0.92); + box-shadow: + 0 0 0 4px rgba(0, 113, 227, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.55); +} + +textarea::placeholder { + color: #9096a0; +} + +kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + padding: 0 8px; + border-radius: 8px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.9); + box-shadow: inset 0 -1px 0 rgba(15, 23, 42, 0.05); +} + +.app-shell { + width: min(1440px, calc(100% - 40px)); + margin: 0 auto; + padding: 28px 0 44px; + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 24px; +} + +.panel { + border-radius: 32px; + border: 1px solid var(--line-soft); + background: var(--surface); + backdrop-filter: blur(18px); + box-shadow: var(--shadow-lg); +} + +.sidebar { + padding: 28px 22px; + display: grid; + gap: 18px; + align-content: start; +} + +.brand-lockup { + display: flex; + gap: 14px; + align-items: center; + padding-bottom: 6px; +} + +.brand-mark { + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, #0a84ff 0%, #5ac8fa 45%, #ff375f 100%); + box-shadow: 0 8px 18px rgba(10, 132, 255, 0.22); +} + +.eyebrow, +.section-kicker, +.sidebar-label { + margin: 0; + color: var(--ink-2); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.sidebar h1, +.hero h2, +.composer h3, +.processing-panel h3, +.result-panel h3 { + margin: 0; + letter-spacing: -0.03em; +} + +.sidebar h1 { + font-size: 1.18rem; +} + +.sidebar-section { + padding: 18px; + border-radius: 24px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.62); + box-shadow: var(--shadow-sm); +} + +.sidebar-section strong { + display: block; + margin-top: 6px; + font-size: 1rem; +} + +.sidebar-copy { + margin: 10px 0 0; + color: var(--ink-2); + line-height: 1.55; +} + +.sidebar-list { + margin: 10px 0 0; + padding-left: 1.1rem; + color: var(--ink-1); + line-height: 1.65; +} + +.workspace { + display: grid; + gap: 24px; +} + +.hero, +.composer, +.processing-panel, +.result-panel { + padding: 28px; +} + +.hero { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(220px, 0.7fr); + gap: 24px; + align-items: center; + overflow: hidden; +} + +.hero h2 { + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 0.96; + max-width: 12ch; +} + +.hero-copy { + margin: 14px 0 0; + color: var(--ink-1); + max-width: 58ch; + line-height: 1.65; +} + +.hero-orbital { + position: relative; + min-height: 220px; +} + +.orb { + position: absolute; + border-radius: 50%; + filter: blur(2px); +} + +.orb-blue { + inset: 18px auto auto 12px; + width: 160px; + height: 160px; + background: radial-gradient( + circle, + rgba(90, 200, 250, 0.9), + rgba(0, 113, 227, 0.08) 72% + ); +} + +.orb-pink { + inset: auto 18px 8px auto; + width: 190px; + height: 190px; + background: radial-gradient( + circle, + rgba(255, 55, 95, 0.5), + rgba(255, 255, 255, 0) 72% + ); +} + +.orb-silver { + inset: 66px auto auto 76px; + width: 164px; + height: 164px; + background: radial-gradient( + circle at 30% 30%, + rgba(255, 255, 255, 0.98), + rgba(214, 217, 223, 0.66) 58%, + rgba(255, 255, 255, 0.14) 100% + ); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.section-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 18px; +} + +.section-heading h3 { + margin-top: 4px; + font-size: 1.48rem; +} + +.prompt-label { + display: inline-block; + margin-bottom: 12px; + color: var(--ink-1); + font-weight: 600; +} + +.composer-meta, +.composer-footer, +.example-strip { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.composer-meta { + margin-top: 14px; + color: var(--ink-2); +} + +.muted { + color: var(--ink-2); +} + +.example-strip { + margin-top: 18px; + align-items: flex-start; +} + +.example-label { + margin-top: 10px; + color: var(--ink-2); + font-size: 0.94rem; + white-space: nowrap; +} + +.example-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.example-chip, +.secondary-button, +.primary-button { + border-radius: 999px; + min-height: 44px; + border: 1px solid var(--line-strong); + transition: + transform 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + opacity 160ms ease; +} + +.example-chip, +.secondary-button { + background: rgba(255, 255, 255, 0.72); + color: var(--ink-1); +} + +.example-chip { + padding: 0.8rem 1rem; + max-width: 100%; + box-shadow: var(--shadow-sm); +} + +.primary-button { + padding: 0.85rem 1.35rem; + background: linear-gradient(180deg, #1482f0 0%, #0071e3 100%); + color: #ffffff; + border-color: transparent; + box-shadow: 0 14px 26px rgba(0, 113, 227, 0.24); + font-weight: 700; +} + +.secondary-button { + padding: 0.8rem 1.15rem; + box-shadow: var(--shadow-sm); +} + +.example-chip:hover, +.primary-button:hover, +.secondary-button:hover { + transform: translateY(-1px); +} + +.primary-button:disabled, +.secondary-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.composer-footer { + margin-top: 22px; +} + +.meta-pills { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.meta-pills span, +.status-badge { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0.45rem 0.85rem; + border-radius: 999px; + background: rgba(0, 113, 227, 0.08); + border: 1px solid rgba(0, 113, 227, 0.12); + color: #0f4c9a; + font-size: 0.94rem; +} + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(320px, 0.9fr) minmax(420px, 1.1fr); + gap: 24px; +} + +.processing-lead { + margin: 0 0 20px; + color: var(--ink-1); + line-height: 1.6; +} + +.progress-track { + height: 10px; + border-radius: 999px; + background: #e7ebf1; + overflow: hidden; +} + +.progress-fill { + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #5ac8fa 0%, #0071e3 100%); + transition: width 180ms ease; +} + +.processing-steps { + list-style: none; + margin: 22px 0 0; + padding: 0; + display: grid; + gap: 14px; +} + +.processing-step { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.processing-step strong { + display: block; + font-size: 0.98rem; +} + +.processing-step p { + margin: 6px 0 0; + color: var(--ink-2); + line-height: 1.55; +} + +.step-dot { + width: 14px; + height: 14px; + border-radius: 50%; + margin-top: 4px; + border: 1px solid #d0d5dc; + background: #ffffff; +} + +.processing-step-done .step-dot { + background: var(--success); + border-color: transparent; +} + +.processing-step-active .step-dot { + background: var(--accent); + border-color: transparent; + box-shadow: 0 0 0 6px rgba(0, 113, 227, 0.12); +} + +.request-meta { + margin: 0; + color: var(--ink-2); + line-height: 1.6; +} + +.error-banner { + margin-top: 16px; + padding: 14px 16px; + border-radius: 20px; + border: 1px solid rgba(255, 59, 48, 0.16); + background: rgba(255, 59, 48, 0.08); + color: #b42318; +} + +.result-stage { + margin-top: 18px; + padding: 18px; + border-radius: 28px; + border: 1px solid var(--line-soft); + background: rgba(255, 255, 255, 0.76); + box-shadow: var(--shadow-sm); +} + +.loading-state, +.empty-state { + min-height: 520px; + display: grid; + place-items: center; + text-align: center; +} + +.result-skeleton { + width: min(100%, 480px); +} + +.skeleton-art, +.skeleton-line { + background: linear-gradient( + 90deg, + rgba(238, 241, 246, 1) 20%, + rgba(247, 248, 250, 1) 50%, + rgba(238, 241, 246, 1) 80% + ); + background-size: 220% 100%; + animation: shimmer 1.6s linear infinite; +} + +.skeleton-art { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 28px; +} + +.skeleton-line { + height: 14px; + margin-top: 16px; + border-radius: 999px; +} + +.skeleton-line-wide { + width: 78%; +} + +.skeleton-line:not(.skeleton-line-wide) { + width: 56%; +} + +.empty-illustration { + width: 180px; + height: 180px; + border-radius: 36px; + background: + radial-gradient( + circle at 30% 30%, + rgba(90, 200, 250, 0.95), + rgba(0, 113, 227, 0.08) 72% + ), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.82), + rgba(239, 241, 246, 0.82) + ); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.empty-state h4 { + margin: 18px 0 10px; + font-size: 1.16rem; +} + +.empty-state p { + margin: 0; + color: var(--ink-2); + max-width: 36ch; + line-height: 1.6; +} + +.image-frame { + overflow: hidden; + border-radius: 24px; + background: linear-gradient(180deg, #eef2f7 0%, #f7f9fc 100%); +} + +.image-frame img { + display: block; + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; +} + +.result-copy { + margin-top: 16px; +} + +.result-caption { + color: var(--ink-2); + font-size: 0.9rem; +} + +.result-copy p { + margin: 8px 0 0; + color: var(--ink-1); + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -20% 0; + } +} + +@media (max-width: 1120px) { + .app-shell, + .dashboard-grid, + .hero { + grid-template-columns: 1fr; + } + + .hero h2 { + max-width: none; + } +} + +@media (max-width: 720px) { + .app-shell { + width: min(100% - 20px, 100%); + padding-top: 14px; + gap: 18px; + } + + .sidebar, + .hero, + .composer, + .processing-panel, + .result-panel { + padding: 22px; + border-radius: 28px; + } + + .section-heading, + .composer-meta, + .composer-footer, + .example-strip { + flex-direction: column; + align-items: flex-start; + } + + .example-list { + width: 100%; + } + + .example-chip { + width: 100%; + text-align: left; + } +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..cb600df --- /dev/null +++ b/public/sw.js @@ -0,0 +1,100 @@ +const STATIC_CACHE_PREFIX = "image-prompt-studio-static"; +const STATIC_CACHE_NAME = `${STATIC_CACHE_PREFIX}-v1`; + +function shouldCache(requestUrl) { + if (requestUrl.origin !== self.location.origin) { + return false; + } + + return ( + requestUrl.pathname.startsWith("/_next/static/") || + requestUrl.pathname === "/manifest.webmanifest" || + requestUrl.pathname === "/pwa-icon.svg" || + requestUrl.pathname === "/pwa-icon-maskable.svg" || + requestUrl.pathname === "/pwa-badge.svg" + ); +} + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + Promise.all([ + self.clients.claim(), + caches.keys().then((cacheNames) => + Promise.all( + cacheNames + .filter( + (cacheName) => + cacheName.startsWith(STATIC_CACHE_PREFIX) && + cacheName !== STATIC_CACHE_NAME, + ) + .map((cacheName) => caches.delete(cacheName)), + ), + ), + ]), + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") { + return; + } + + const requestUrl = new URL(event.request.url); + + if (!shouldCache(requestUrl)) { + return; + } + + event.respondWith( + caches.open(STATIC_CACHE_NAME).then(async (cache) => { + const cachedResponse = await cache.match(event.request); + + if (cachedResponse) { + return cachedResponse; + } + + const networkResponse = await fetch(event.request); + + if (networkResponse.ok) { + cache.put(event.request, networkResponse.clone()); + } + + return networkResponse; + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + const destinationUrl = + typeof event.notification.data?.url === "string" + ? new URL(event.notification.data.url, self.location.origin).href + : `${self.location.origin}/studio`; + + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then( + async (windowClients) => { + for (const client of windowClients) { + if ("focus" in client) { + await client.focus(); + + if ("navigate" in client) { + await client.navigate(destinationUrl); + } + + return; + } + } + + if (self.clients.openWindow) { + await self.clients.openWindow(destinationUrl); + } + }, + ), + ); +}); diff --git a/scripts/load-env.mjs b/scripts/load-env.mjs new file mode 100644 index 0000000..0ee0afe --- /dev/null +++ b/scripts/load-env.mjs @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +export function loadEnvFiles({ + cwd = process.cwd(), + files = [".env", ".env.local"], + override = false, +} = {}) { + const originalKeys = new Set(Object.keys(process.env)); + + for (const fileName of files) { + const filePath = path.join(cwd, fileName); + + try { + const source = readFileSync(filePath, "utf8"); + applyEnvSource(source, { override, originalKeys }); + } catch { + continue; + } + } +} + +function applyEnvSource(source, { override, originalKeys }) { + for (const line of source.split(/\r?\n/)) { + const trimmedLine = line.trim(); + + if (!trimmedLine || trimmedLine.startsWith("#")) { + continue; + } + + const separatorIndex = trimmedLine.indexOf("="); + + if (separatorIndex === -1) { + continue; + } + + const key = trimmedLine.slice(0, separatorIndex).trim(); + const rawValue = trimmedLine.slice(separatorIndex + 1).trim(); + const value = rawValue.replace(/^['"]|['"]$/g, ""); + + if (!key) { + continue; + } + + if (!override && originalKeys.has(key)) { + continue; + } + + process.env[key] = value; + } +} diff --git a/scripts/reset-admin.mjs b/scripts/reset-admin.mjs new file mode 100644 index 0000000..c0fd57a --- /dev/null +++ b/scripts/reset-admin.mjs @@ -0,0 +1,12 @@ +import { loadEnvFiles } from "./load-env.mjs"; + +loadEnvFiles({ override: true }); + +const { appConfig } = await import("../lib/config.js"); +const { syncAdminUserFromConfig } = await import("../lib/db.js"); + +syncAdminUserFromConfig(); + +console.log( + `[image-prompt-studio] synced admin user "${appConfig.adminUsername}" from environment`, +); diff --git a/scripts/start-standalone.mjs b/scripts/start-standalone.mjs new file mode 100644 index 0000000..db14735 --- /dev/null +++ b/scripts/start-standalone.mjs @@ -0,0 +1,27 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { loadEnvFiles } from "./load-env.mjs"; + +loadEnvFiles(); + +const serverEntry = path.join( + process.cwd(), + ".next", + "standalone", + "server.js", +); + +const child = spawn(process.execPath, [serverEntry], { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +}); diff --git a/tests/quota.test.ts b/tests/quota.test.ts new file mode 100644 index 0000000..c434a7c --- /dev/null +++ b/tests/quota.test.ts @@ -0,0 +1,9 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getCurrentPeriodKey } from "../lib/quota-core"; + +test("getCurrentPeriodKey formats a UTC year-month string", () => { + const periodKey = getCurrentPeriodKey(new Date("2026-04-24T08:00:00.000Z")); + + assert.equal(periodKey, "2026-04"); +}); diff --git a/tests/request-schema.test.ts b/tests/request-schema.test.ts new file mode 100644 index 0000000..22a59cb --- /dev/null +++ b/tests/request-schema.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseGenerateRequest } from "../lib/request-schema"; + +test("parseGenerateRequest returns normalized payload", () => { + const payload = parseGenerateRequest( + { + prompt: "A premium product poster", + size: "1536x1024", + quality: "high", + outputFormat: "png", + background: "auto", + count: 2, + moderation: "auto", + user: "browser-demo-session-id", + }, + "gpt-image-2", + ); + + assert.deepEqual(payload, { + model: "gpt-image-2", + prompt: "A premium product poster", + size: "1536x1024", + quality: "high", + n: 2, + moderation: "auto", + output_format: "png", + background: "auto", + user: "browser-demo-session-id", + }); +}); + +test("parseGenerateRequest rejects transparent jpeg output", () => { + assert.throws( + () => + parseGenerateRequest( + { + prompt: "A cutout product shot", + outputFormat: "jpeg", + background: "transparent", + }, + "gpt-image-2", + ), + /JPEG 不支持透明背景/, + ); +}); + +test("parseGenerateRequest rejects oversized count", () => { + assert.throws( + () => + parseGenerateRequest( + { + prompt: "too many", + count: 5, + }, + "gpt-image-2", + ), + /最多生成 4 张图片/, + ); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d4a2d2f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "es2023"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "types": ["node"] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +}