commit 9bf212f67ca3c570a7f2f023d90601c65afa5d28 Author: zhangheng Date: Sat Apr 25 11:26:33 2026 +0800 feat(project): init 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"] +}