From 091a19556c2e025309f280354839f21448c1024f Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 19 May 2026 15:18:13 +0800 Subject: [PATCH] feat: add protected login workspace --- .env.local.example | 7 + .memory/worklog.json | 46 ++ .project.json | 42 +- RULES.md | 10 +- deploy/.env.production.example | 7 + src/app/api/auth/check/route.ts | 17 + src/app/api/auth/login/route.ts | 37 ++ src/app/api/auth/logout/route.ts | 21 + src/app/globals.css | 443 ++++++++++++++++++ src/app/login/page.tsx | 170 +++++++ src/app/page.tsx | 11 + .../login/AnimatedLoginCharacters.tsx | 84 ++++ src/lib/auth.ts | 80 ++++ src/middleware.ts | 95 ++++ 14 files changed, 1064 insertions(+), 6 deletions(-) create mode 100644 src/app/api/auth/check/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/components/login/AnimatedLoginCharacters.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/middleware.ts diff --git a/.env.local.example b/.env.local.example index 45e4802..2774757 100644 --- a/.env.local.example +++ b/.env.local.example @@ -11,3 +11,10 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3 # 生产环境填写公网入口,用于把 /api/img/... 补成 Seedance 可访问的绝对 URL。 PUBLIC_APP_URL= + +# Web login. Local Docker can use any private values; production values only live on VPS. +WEB_AUTH_USERNAME= +WEB_AUTH_PASSWORD= +WEB_AUTH_SESSION_SECRET= +WEB_AUTH_COOKIE_NAME=ai_toy_session +WEB_AUTH_COOKIE_SECURE=false diff --git a/.memory/worklog.json b/.memory/worklog.json index 37af6d2..1d6883c 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -803,6 +803,52 @@ "message": "fix: polish regeneration controls", "hash": "254c2c3", "files_changed": 2 + }, + { + "ts": "2026-05-19T14:45:29+08:00", + "type": "commit", + "message": "fix: polish regeneration controls", + "hash": "aa03bae", + "files_changed": 2 + }, + { + "ts": "2026-05-19T06:50:03Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 1 项未提交变更 · 最近提交:fix: polish regeneration controls", + "files_changed": 1 + }, + { + "ts": "2026-05-19T14:56:30+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 14:56 (+5, ~1)", + "hash": "010cb44", + "files_changed": 8 + }, + { + "ts": "2026-05-19T07:00:03Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 8 项未提交变更 · 最近提交:auto-save 2026-05-19 14:56 (+5, ~1)", + "files_changed": 8 + }, + { + "ts": "2026-05-19T15:01:55+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 15:01 (+1, ~8, -1)", + "hash": "e723029", + "files_changed": 9 + }, + { + "ts": "2026-05-19T07:10:03Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 master · 2 项未提交变更 · 最近提交:auto-save 2026-05-19 15:01 (+1, ~8, -1)", + "files_changed": 2 + }, + { + "ts": "2026-05-19T15:12:47+08:00", + "type": "commit", + "message": "auto-save 2026-05-19 15:12 (~2)", + "hash": "d3e29e7", + "files_changed": 2 } ] } diff --git a/.project.json b/.project.json index 80c9170..dcf7e7f 100644 --- a/.project.json +++ b/.project.json @@ -5,12 +5,38 @@ "kind": "tool", "created": "2026-05-18", "urls": [ - { "type": "app", "url": "https://ai-toy.kang-kang.com", "label": "VPS 生产" }, - { "type": "app", "url": "http://localhost:4560", "label": "本地 Docker" } + { + "type": "app", + "url": "https://ai-toy.kang-kang.com", + "label": "VPS 生产" + }, + { + "type": "app", + "url": "https://ai-toy.kang-kang.com/login", + "label": "VPS 登录" + }, + { + "type": "app", + "url": "http://localhost:4560", + "label": "本地 Docker" + } ], "credentials": [ - { "name": "OPENAI_API_KEY", "env": "OPENAI_API_KEY", "note": "GPT 文本/结构化/图片生成;没填则图片 mock" }, - { "name": "SEEDANCE_API_KEY", "env": "SEEDANCE_API_KEY", "note": "Seedance 视频生成;没填则视频接口不可用" } + { + "name": "OPENAI_API_KEY", + "env": "OPENAI_API_KEY", + "note": "GPT 文本/结构化/图片生成;没填则图片 mock" + }, + { + "name": "SEEDANCE_API_KEY", + "env": "SEEDANCE_API_KEY", + "note": "Seedance 视频生成;没填则视频接口不可用" + }, + { + "name": "WEB_LOGIN", + "env": "WEB_AUTH_USERNAME/WEB_AUTH_PASSWORD/WEB_AUTH_SESSION_SECRET", + "note": "网页登录;生产值只放 VPS deploy/.env.production 和 /root/ai-toy-patent-workflow-login.txt" + } ], "ports": [ { @@ -28,5 +54,11 @@ "Docker Compose local/prod parity", "Coolify Traefik" ], - "ownership": "personal" + "ownership": "personal", + "quick_login": { + "label": "AI Toy Patent / 登录", + "url": "https://ai-toy.kang-kang.com/login", + "username": "kangwan", + "password": "22668050fb50f6e95cb5e32c" + } } diff --git a/RULES.md b/RULES.md index 9753033..f3fefc8 100644 --- a/RULES.md +++ b/RULES.md @@ -12,13 +12,17 @@ - 服务名 / 容器名:`ai-toy-patent-workflow` - 服务器路径:`/opt/ai-toy-patent-workflow` - 主站 / 前端:https://ai-toy.kang-kang.com +- 登录页:https://ai-toy.kang-kang.com/login - 本地 Docker:http://localhost:4560 - API / 后端:内置 Next.js API Route(生产同域名) - 文档 / 解析:无 - 管理后台:无 ## 快捷登录 -- 无登录系统(本地工具) +- 已启用应用内登录页 +- 登录地址:`https://ai-toy.kang-kang.com/login` +- 用户名 / 密码:见 `.project.json.quick_login`;生产备份在 VPS `/root/ai-toy-patent-workflow-login.txt` +- 会话:HttpOnly HMAC Cookie,生产变量只放 VPS `deploy/.env.production` ## 元数据回写清单 - 改公网域名或迁移部署时,更新 `.project.json.urls` + 本节 @@ -39,9 +43,13 @@ - `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128` - `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3` - `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL +- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` — 网页登录;真实值只放 `.env.local` 和 VPS `deploy/.env.production` +- `WEB_AUTH_COOKIE_NAME` — 默认 `ai_toy_session` +- `WEB_AUTH_COOKIE_SECURE` — 本地 `false`,生产 `true` - 配置位置:`.env.local`(gitignored),参考 `.env.local.example` - 生产配置模板:`deploy/.env.production.example`;真实生产值只放 VPS 的 `/opt/ai-toy-patent-workflow/deploy/.env.production` - 图片生成未配置 GPT Key 时回退 mock(SVG 占位图),视频生成不 mock,必须配置 Seedance Key +- 除 `/login`、`/api/auth/*` 和 `/api/img/*` 外,页面与 API 都需要登录;`/api/img/*` 保持公开是为了 Seedance 能从公网读取参考图 ## 规则 - 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md` diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 098ac10..26b909d 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -8,3 +8,10 @@ GPT_API_BASE=https://api.openai.com/v1 SEEDANCE_API_KEY= SEEDANCE_MODEL=doubao-seedance-2-0-260128 SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3 + +# Web login. Keep real values only on the VPS at deploy/.env.production. +WEB_AUTH_USERNAME= +WEB_AUTH_PASSWORD= +WEB_AUTH_SESSION_SECRET= +WEB_AUTH_COOKIE_NAME=ai_toy_session +WEB_AUTH_COOKIE_SECURE=true diff --git a/src/app/api/auth/check/route.ts b/src/app/api/auth/check/route.ts new file mode 100644 index 0000000..20cf15f --- /dev/null +++ b/src/app/api/auth/check/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { AUTH_COOKIE_NAME, verifyAuthToken } from '@/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(req: Request) { + const cookie = req.headers.get('cookie') || ''; + const token = cookie + .split(';') + .map(part => part.trim()) + .find(part => part.startsWith(`${AUTH_COOKIE_NAME}=`)) + ?.slice(AUTH_COOKIE_NAME.length + 1); + const username = verifyAuthToken(token ? decodeURIComponent(token) : ''); + if (!username) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + return NextResponse.json({ ok: true, username }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..27c3d5b --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { AUTH_COOKIE_NAME, authCookieSecure, configuredUsername, isAuthConfigured, makeAuthToken, validateCredentials } from '@/lib/auth'; +import { recordEvent } from '@/lib/auditDb'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(req: Request) { + if (!isAuthConfigured()) { + recordEvent({ action: 'auth.login_unconfigured', targetType: 'auth', status: 'error', message: 'WEB_AUTH not configured' }); + return NextResponse.json({ error: 'WEB_AUTH not configured' }, { status: 503 }); + } + + const body = await req.json().catch(() => ({})) as { username?: unknown; password?: unknown; remember?: unknown }; + const username = String(body.username || ''); + const password = String(body.password || ''); + const remember = body.remember !== false; + + if (!validateCredentials(username, password)) { + recordEvent({ action: 'auth.login_failed', targetType: 'auth', targetId: username.trim() || null, status: 'error' }); + return NextResponse.json({ error: '用户名或密码不正确' }, { status: 401 }); + } + + const ttlSeconds = remember ? 60 * 60 * 24 * 30 : 60 * 60 * 12; + const response = NextResponse.json({ ok: true, username: configuredUsername() }); + response.cookies.set({ + name: AUTH_COOKIE_NAME, + value: makeAuthToken(configuredUsername(), ttlSeconds), + maxAge: ttlSeconds, + httpOnly: true, + secure: authCookieSecure(), + sameSite: 'lax', + path: '/', + }); + recordEvent({ action: 'auth.login_ok', targetType: 'auth', targetId: configuredUsername(), status: 'ok', metadata: { remember } }); + return response; +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..dcd1eee --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { AUTH_COOKIE_NAME, authCookieSecure } from '@/lib/auth'; +import { recordEvent } from '@/lib/auditDb'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST() { + const response = NextResponse.json({ ok: true }); + response.cookies.set({ + name: AUTH_COOKIE_NAME, + value: '', + maxAge: 0, + httpOnly: true, + secure: authCookieSecure(), + sameSite: 'lax', + path: '/', + }); + recordEvent({ action: 'auth.logout', targetType: 'auth', status: 'ok' }); + return response; +} diff --git a/src/app/globals.css b/src/app/globals.css index a9f4d7b..02bfa89 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -139,6 +139,449 @@ input, textarea { @apply inline-block text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-300/80; } +/* ===== Login ===== */ +.login-page { + position: relative; + min-height: 100vh; + overflow: hidden; + background: + linear-gradient(135deg, rgba(10, 10, 15, 0.96), rgba(18, 18, 28, 0.98)), + linear-gradient(90deg, rgba(139, 92, 246, 0.12), rgba(59, 130, 246, 0.08)); + color: #fff; +} +.login-backdrop { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px), + linear-gradient(135deg, rgba(139,92,246,0.18), transparent 48%, rgba(59,130,246,0.14)); + background-size: 42px 42px, 42px 42px, auto; + mask-image: linear-gradient(180deg, rgba(0,0,0,0.92), rgba(0,0,0,0.5)); +} +.login-shell { + position: relative; + z-index: 1; + min-height: 100vh; + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(360px, 430px); + align-items: center; + gap: 56px; + width: min(1120px, calc(100vw - 48px)); + margin: 0 auto; + padding: 42px 0; +} +.login-showcase { + min-width: 0; +} +.login-brand-mark { + display: inline-flex; + align-items: center; + height: 30px; + padding: 0 11px; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 8px; + background: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.76); + font-size: 12px; + font-weight: 700; +} +.login-showcase h1 { + margin: 22px 0 10px; + font-size: clamp(38px, 6vw, 72px); + line-height: 1; + font-weight: 760; +} +.login-showcase p { + max-width: 520px; + margin: 0; + color: rgba(255,255,255,0.52); + font-size: 15px; + line-height: 1.7; +} +.login-character-strip { + margin-top: 34px; + width: min(620px, 100%); + height: 300px; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.035)), + rgba(5, 5, 10, 0.45); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 24px 70px rgba(0,0,0,0.32); + overflow: hidden; +} +.login-panel { + display: grid; + gap: 14px; + padding: 24px; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + background: rgba(9, 9, 14, 0.76); + backdrop-filter: blur(18px); + box-shadow: 0 24px 90px rgba(0,0,0,0.44), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-panel__eyebrow { + color: rgba(167,139,250,0.86); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; +} +.login-panel h2 { + margin: 6px 0 4px; + font-size: 22px; + font-weight: 720; +} +.login-field { + display: flex; + align-items: center; + gap: 11px; + height: 46px; + padding: 0 12px; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; + background: rgba(255,255,255,0.055); + color: rgba(255,255,255,0.48); + transition: border-color .18s ease, background .18s ease, box-shadow .18s ease; +} +.login-field:focus-within { + border-color: rgba(167,139,250,0.62); + background: rgba(255,255,255,0.08); + box-shadow: 0 0 0 4px rgba(139,92,246,0.16); +} +.login-field input { + min-width: 0; + flex: 1; + height: 100%; + border: 0; + outline: 0; + background: transparent; + color: #fff; + font-size: 15px; +} +.login-field input::placeholder { + color: rgba(255,255,255,0.3); +} +.login-icon-button { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: 0; + border-radius: 8px; + background: transparent; + color: rgba(255,255,255,0.55); + cursor: pointer; +} +.login-icon-button:hover { + background: rgba(255,255,255,0.08); + color: #fff; +} +.login-options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 24px; + color: rgba(255,255,255,0.58); + font-size: 13px; +} +.login-options label, +.login-success { + display: inline-flex; + align-items: center; + gap: 7px; +} +.login-options input { + width: 15px; + height: 15px; + accent-color: #8b5cf6; +} +.login-success { + color: #86efac; +} +.login-message { + min-height: 20px; + color: #fca5a5; + font-size: 13px; +} +.login-submit { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 46px; + border: 0; + border-radius: 8px; + background: #fff; + color: #08080c; + font-size: 15px; + font-weight: 720; + cursor: pointer; + transition: transform .18s ease, background .18s ease, opacity .18s ease; +} +.login-submit:hover { + background: #f1edff; +} +.login-submit:active { + transform: translateY(1px); +} +.login-submit:disabled { + cursor: wait; + opacity: .72; +} +.login-character-stage { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} +.login-stage-grid { + position: absolute; + inset: 22px; + border: 1px dashed rgba(255,255,255,0.12); + border-radius: 8px; +} +.login-characters-container { + position: absolute; + z-index: 1; + left: 50%; + bottom: -8px; + width: 550px; + height: 400px; + transform: translateX(-50%) scale(0.66); + transform-origin: bottom center; +} +.login-character-base { + position: absolute; + left: 28px; + right: 18px; + bottom: -12px; + height: 28px; + border-radius: 999px; + background: radial-gradient(ellipse at center, rgba(0,0,0,0.52), rgba(0,0,0,0) 72%); + filter: blur(1px); +} +.login-figure { + position: absolute; + bottom: 0; + transform-origin: bottom center; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.2); + transition: transform .7s cubic-bezier(.4,0,.2,1), height .55s cubic-bezier(.4,0,.2,1); + filter: drop-shadow(0 34px 32px rgba(0,0,0,0.42)); +} +.login-figure::before { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + background: radial-gradient(circle at 30% 18%, rgba(255,255,255,0.34), transparent 24%), linear-gradient(90deg, rgba(255,255,255,0.18), transparent 26%, transparent 62%, rgba(0,0,0,0.3)); + mix-blend-mode: screen; +} +.login-figure::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + background: linear-gradient(105deg, rgba(255,255,255,0.2), transparent 30%, rgba(0,0,0,0.26)); + box-shadow: inset 12px 0 22px rgba(255,255,255,0.09), inset -18px 0 28px rgba(0,0,0,0.22), inset 0 -28px 36px rgba(0,0,0,0.18); +} +.login-figure__rim { + position: absolute; + inset: 8px; + z-index: 1; + border: 1px solid rgba(255,255,255,0.16); + border-radius: inherit; +} +.login-figure__shine { + position: absolute; + z-index: 1; + left: 18%; + top: 18%; + width: 42%; + height: 11%; + border-radius: 999px; + background: rgba(255,255,255,0.22); + transform: rotate(-14deg); +} +.login-figure__panel { + position: absolute; + z-index: 1; + border: 1px solid rgba(255,255,255,0.13); + background: rgba(255,255,255,0.1); +} +.login-figure__signal { + position: absolute; + z-index: 2; + display: flex; + gap: 6px; +} +.login-figure__signal span { + width: 7px; + height: 7px; + border-radius: 999px; + background: rgba(255,255,255,0.56); +} +.login-figure--purple { + left: 72px; + width: 176px; + height: 392px; + z-index: 1; + background: linear-gradient(165deg, #9b7bff 0%, #6c3ff5 46%, #3e1eb8 100%); + border-radius: 22px 22px 8px 8px; +} +.login-figure--black { + left: 240px; + width: 120px; + height: 310px; + z-index: 2; + background: linear-gradient(165deg, #34343a 0%, #202126 48%, #111217 100%); + border-radius: 18px 18px 6px 6px; +} +.login-figure--orange { + left: 0; + width: 240px; + height: 150px; + z-index: 3; + background: linear-gradient(160deg, #ffb27f 0%, #ff8f61 56%, #dc6f48 100%); + border-radius: 120px 120px 0 0; +} +.login-figure--yellow { + left: 310px; + width: 140px; + height: 230px; + z-index: 4; + background: linear-gradient(160deg, #fff17a 0%, #e8d754 58%, #bfa73a 100%); + border-radius: 70px 70px 0 0; +} +.login-figure--purple .login-figure__panel { left: 22px; bottom: 88px; width: 36px; height: 152px; border-radius: 999px; } +.login-figure--purple .login-figure__signal { left: 26px; bottom: 46px; } +.login-figure--black .login-figure__panel { right: 16px; bottom: 40px; width: 12px; height: 190px; border-radius: 999px; } +.login-figure--black .login-figure__signal { right: 18px; top: 88px; flex-direction: column; } +.login-figure--orange .login-figure__panel { left: 42px; bottom: 24px; width: 88px; height: 18px; border-radius: 999px; } +.login-figure--orange .login-figure__signal { right: 54px; bottom: 28px; } +.login-figure--yellow .login-figure__panel { left: 24px; bottom: 44px; width: 92px; height: 24px; border-radius: 999px; background: rgba(45,45,45,0.08); } +.login-figure--yellow .login-figure__signal { left: 34px; top: 124px; } +.login-figure--yellow .login-figure__signal span, +.login-figure--orange .login-figure__signal span { background: rgba(45,45,45,0.36); } +.login-eyes { + position: absolute; + z-index: 2; + display: flex; + transition: left .35s cubic-bezier(.4,0,.2,1), top .35s cubic-bezier(.4,0,.2,1); +} +.login-eyes--purple { left: 75px; top: 25px; gap: 32px; } +.login-eyes--black { left: 26px; top: 32px; gap: 24px; } +.login-eyes--orange { left: 112px; top: 60px; gap: 32px; } +.login-eyes--yellow { left: 52px; top: 40px; gap: 24px; } +.login-eye { + position: relative; + width: 22px; + height: 22px; + border-radius: 999px; + background: white; + overflow: hidden; +} +.login-eye--small { width: 20px; height: 20px; } +.login-eye::after, +.login-pupil::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 8px; + height: 8px; + border-radius: 50%; + background: #2d2d2d; + transform: translate(var(--eye-x), var(--eye-y)); + transition: transform .08s linear, opacity .2s ease; +} +.login-eye::after { margin: -4px 0 0 -4px; } +.login-pupil { + position: relative; + width: 12px; + height: 12px; + border-radius: 50%; +} +.login-pupil::after { + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; +} +.login-mouth { + position: absolute; + z-index: 2; + background: #2d2d2d; + transition: width .35s cubic-bezier(.4,0,.2,1), height .35s cubic-bezier(.4,0,.2,1), border-radius .35s cubic-bezier(.4,0,.2,1), transform .35s cubic-bezier(.4,0,.2,1); +} +.login-mouth--purple { left: 97px; top: 57px; width: 24px; height: 8px; border-radius: 0 0 12px 12px; } +.login-mouth--orange { left: 126px; top: 92px; width: 26px; height: 13px; border-radius: 0 0 13px 13px; } +.login-yellow-mouth { + position: absolute; + z-index: 2; + left: 40px; + top: 88px; +} +.login-yellow-mouth path { + stroke: #2d2d2d; + stroke-width: 3; + fill: none; + stroke-linecap: round; +} +.login-character-stage[data-mood="typing"] .login-figure--purple { height: 430px; transform: skewX(-10deg) translateX(36px); } +.login-character-stage[data-mood="typing"] .login-figure--black { transform: skewX(7deg) translateX(6px); } +.login-character-stage[data-mood="typing"] .login-figure--orange { transform: skewX(-5deg); } +.login-character-stage[data-mood="typing"] .login-figure--yellow { transform: skewX(4deg); } +.login-character-stage[data-mood="typing"] .login-mouth--purple { width: 7px; height: 32px; border-radius: 0; transform: translate(14px, -28px) skewX(10deg); } +.login-character-stage[data-mood="typing"] .login-mouth--orange { width: 14px; height: 14px; border-radius: 50%; transform: translateX(6px); } +.login-character-stage[data-mood="peek"] .login-figure--purple, +.login-character-stage[data-mood="peek"] .login-figure--black { transform: skewX(0deg) translateY(-10px); } +.login-character-stage[data-mood="peek"] .login-eyes--purple { left: 40px; top: 14px; } +.login-character-stage[data-mood="peek"] .login-eyes--black { left: 2px; top: 20px; } +.login-character-stage[data-mood="peek"] .login-eyes--orange { left: 68px; top: 48px; } +.login-character-stage[data-mood="peek"] .login-eyes--yellow { left: 10px; top: 28px; } +.login-character-stage[data-mood="peek"] .login-eye::after, +.login-character-stage[data-mood="peek"] .login-pupil::after { transform: translate(-8px, -6px); } +.login-character-stage[data-mood="error"] .login-characters-container { animation: login-shake .28s ease-in-out 2; } +.login-character-stage[data-mood="error"] .login-eye { height: 10px; margin-top: 4px; } +.login-character-stage[data-mood="error"] .login-mouth--purple, +.login-character-stage[data-mood="error"] .login-mouth--orange { border-radius: 12px 12px 0 0; transform: translateY(10px); } +.login-character-stage[data-mood="success"] .login-characters-container { animation: login-stage-success .75s cubic-bezier(.34,1.56,.64,1) both; } +.login-character-stage[data-mood="success"] .login-mouth--purple { width: 30px; height: 16px; border-radius: 0 0 15px 15px; } +.login-character-stage[data-mood="success"] .login-mouth--orange { width: 32px; height: 18px; border-radius: 0 0 16px 16px; } +@keyframes login-shake { + 0%, 100% { translate: 0 0; } + 33% { translate: -5px 0; } + 66% { translate: 5px 0; } +} +@keyframes login-stage-success { + 0% { translate: 0 0; } + 45% { translate: 0 -22px; } + 100% { translate: 0 -8px; } +} +@media (max-width: 860px) { + .login-shell { + width: min(100vw - 28px, 460px); + grid-template-columns: 1fr; + gap: 18px; + align-content: center; + } + .login-showcase h1 { + font-size: 40px; + } + .login-showcase p { + font-size: 13px; + } + .login-character-strip { + height: 210px; + } + .login-characters-container { + transform: translateX(-50%) scale(0.46); + } +} + /* ===== Divider ===== */ .divider-line { @apply h-px w-full; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..5ce1ba0 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,170 @@ +'use client'; + +import type { FormEvent } from 'react'; +import { Suspense, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { AnimatedLoginCharacters, type LoginCharacterMood } from '@/components/login/AnimatedLoginCharacters'; + +type LoginStatus = 'idle' | 'loading' | 'success'; + +function Icon({ type }: { type: 'user' | 'lock' | 'eye' | 'eyeOff' | 'arrow' | 'check' }) { + const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }; + if (type === 'user') return ; + if (type === 'lock') return ; + if (type === 'eye') return ; + if (type === 'eyeOff') return ; + if (type === 'check') return ; + return ; +} + +function LoginInner() { + const searchParams = useSearchParams(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [remember, setRemember] = useState(true); + const [showPassword, setShowPassword] = useState(false); + const [activeField, setActiveField] = useState<'username' | 'password' | null>(null); + const [hasError, setHasError] = useState(false); + const [message, setMessage] = useState(''); + const [status, setStatus] = useState('idle'); + const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const onPointerMove = (event: PointerEvent) => { + const centerX = window.innerWidth / 2; + const centerY = window.innerHeight / 2; + const nextX = Math.max(-1, Math.min(1, (event.clientX - centerX) / centerX)); + const nextY = Math.max(-1, Math.min(1, (event.clientY - centerY) / centerY)); + setEyeOffset({ x: nextX * 8, y: nextY * 5.5 }); + }; + window.addEventListener('pointermove', onPointerMove); + return () => window.removeEventListener('pointermove', onPointerMove); + }, []); + + const disabled = status === 'loading' || status === 'success'; + const mood: LoginCharacterMood = useMemo(() => { + if (status === 'success') return 'success'; + if (hasError) return 'error'; + if (showPassword && activeField === 'password') return 'peek'; + if (activeField || username || password) return 'typing'; + return 'idle'; + }, [activeField, hasError, password, showPassword, status, username]); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setHasError(false); + setMessage(''); + if (!username.trim() || !password) { + setHasError(true); + setMessage('账号和密码都要填。'); + return; + } + + setStatus('loading'); + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, remember }), + }); + if (!res.ok) throw new Error(); + setStatus('success'); + const next = searchParams.get('next') || '/'; + window.setTimeout(() => { window.location.href = next.startsWith('/') ? next : '/'; }, 350); + } catch { + setStatus('idle'); + setHasError(true); + setMessage('账号或密码不对。'); + } + } + + return ( +
+
+
+
+
AI Toy
+

AI Toy Patent

+

进入玩具 IP 生成工作台,管理上传、生成、图库和操作记录。

+
+ +
+
+ +
+
+
Private Workspace
+

登录工作台

+
+ + + + + +
+ + {status === 'success' ? 已通过 : null} +
+ + {message ?
{message}
: null} + + +
+
+
+ ); +} + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8116de1..e11d55c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -271,6 +271,11 @@ export default function Home() { } } + async function handleLogout() { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); + window.location.href = '/login'; + } + return (
)} + {provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider} diff --git a/src/components/login/AnimatedLoginCharacters.tsx b/src/components/login/AnimatedLoginCharacters.tsx new file mode 100644 index 0000000..343e2a5 --- /dev/null +++ b/src/components/login/AnimatedLoginCharacters.tsx @@ -0,0 +1,84 @@ +'use client'; + +import type { CSSProperties } from 'react'; + +export type LoginCharacterMood = 'idle' | 'typing' | 'peek' | 'error' | 'success'; + +type EyeKind = 'eye' | 'small-eye' | 'pupil'; + +type FigureSpec = { + id: 'purple' | 'black' | 'orange' | 'yellow'; + eyeKind: EyeKind; + mouth?: 'purple' | 'orange' | 'yellow'; + marks?: number; +}; + +const FIGURES: FigureSpec[] = [ + { id: 'purple', eyeKind: 'eye', mouth: 'purple', marks: 3 }, + { id: 'black', eyeKind: 'small-eye', marks: 2 }, + { id: 'orange', eyeKind: 'pupil', mouth: 'orange', marks: 2 }, + { id: 'yellow', eyeKind: 'pupil', mouth: 'yellow', marks: 3 }, +]; + +function Eyes({ figure, kind }: { figure: FigureSpec['id']; kind: EyeKind }) { + const className = kind === 'pupil' ? 'login-pupil' : kind === 'small-eye' ? 'login-eye login-eye--small' : 'login-eye'; + + return ( + + + + + ); +} + +function Mouth({ type }: { type: NonNullable }) { + if (type === 'yellow') { + return ( + + + + ); + } + + return ; +} + +function Figure({ spec }: { spec: FigureSpec }) { + return ( +
+ + + + + {Array.from({ length: spec.marks ?? 0 }).map((_, index) => )} + + + {spec.mouth ? : null} +
+ ); +} + +export function AnimatedLoginCharacters({ + mood, + eyeOffset, +}: { + mood: LoginCharacterMood; + eyeOffset: { x: number; y: number }; +}) { + return ( +