auto-save 2026-05-15 15:21 (+1, ~9)
This commit is contained in:
@@ -1,11 +1,5 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 23:51 (~1)",
|
||||
"ts": "2026-05-13T15:53:09Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "12daaa2",
|
||||
@@ -3250,6 +3244,13 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-15 15:10 (~2)",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-15T15:15:47+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-15 15:15 (~4)",
|
||||
"hash": "7ee9ea2",
|
||||
"files_changed": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "生产站点 Basic Auth 登录;用户名写 RULES.md,密码只放服务器 /root/skg-marketing-studio-login.txt,Nginx 使用 deploy/.htpasswd 哈希文件",
|
||||
"name" : "WEB_BASIC_AUTH",
|
||||
"storage" : "/root/skg-marketing-studio-login.txt / deploy/.htpasswd",
|
||||
"description" : "生产网页登录;用户名写 RULES.md,密码只放服务器 /root/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name" : "WEB_LOGIN",
|
||||
"storage" : "/root/skg-marketing-studio-login.txt / deploy/.env.production",
|
||||
"type" : "web_login"
|
||||
}
|
||||
],
|
||||
|
||||
11
RULES.md
11
RULES.md
@@ -14,22 +14,22 @@
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
- 发布状态:已部署并验证(2026-05-15);`https://marketing.skg.com` 已启用 Basic Auth,认证后首页 200,`/api/health` 返回 `ok:true`
|
||||
- 发布状态:已部署并验证(2026-05-15);`https://marketing.skg.com` 已启用应用内登录页,认证后首页 200,`/api/health` 返回 `ok:true`
|
||||
- 主站 / 前端:`https://marketing.skg.com`
|
||||
- API / 后端:`https://marketing.skg.com/api`
|
||||
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
|
||||
- 管理后台:待定
|
||||
- 服务器目录:`/opt/skg-marketing-studio`
|
||||
- 生产启动:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`
|
||||
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出并做 Basic Auth,认证后反代 `/api/` 到 `skg-marketing-api:4291`,`api` 容器跑 FastAPI 4291;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;未登录访问工作台跳转 `/login/`,`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`
|
||||
- 登录凭证:Nginx 使用服务器 `/opt/skg-marketing-studio/deploy/.htpasswd`;明文备份只放服务器 `/root/skg-marketing-studio-login.txt`
|
||||
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
|
||||
|
||||
## 快捷登录
|
||||
- 登录地址:`https://marketing.skg.com`
|
||||
- 登录地址:`https://marketing.skg.com/login/`
|
||||
- 用户名:`skg`
|
||||
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
|
||||
- 说明:当前是生产入口 Basic Auth;数据库密码、API Key、服务器 root 密码不要写这里
|
||||
- 说明:当前是生产入口应用内登录页;数据库密码、API Key、服务器 root 密码不要写这里
|
||||
|
||||
## 元数据回写清单
|
||||
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
|
||||
@@ -50,6 +50,7 @@
|
||||
- `MINIMAX_TTS_BASE_URL` / `MINIMAX_TTS_MODEL` / `MINIMAX_TTS_VOICE_ID`:MiniMax 配音端点、模型和兜底音色配置
|
||||
- `MINIMAX_TTS_VOICE_POOL`:MiniMax 英文随机音色池;当前默认男声 `English_magnetic_voiced_man`、女声 `English_Upbeat_Woman`、成熟声 `English_MaturePartner`
|
||||
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
|
||||
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
|
||||
|
||||
## 规则
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
LLM_API_KEY=
|
||||
|
||||
# 应用登录(生产 Nginx auth_request 使用;本地 http 反代测试时 COOKIE_SECURE=false)
|
||||
WEB_AUTH_USERNAME=skg
|
||||
WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
WEB_AUTH_COOKIE_SECURE=false
|
||||
|
||||
# 模型分工
|
||||
ASR_MODEL=whisper-1
|
||||
ASR_FALLBACK_MODEL=gemini-2.5-flash
|
||||
|
||||
@@ -7,6 +7,13 @@ KEYFRAME_COUNT=12
|
||||
CORS_ORIGINS=https://marketing.skg.com
|
||||
API_PORT=4291
|
||||
|
||||
# Web login. Keep real password and session secret only on the server.
|
||||
WEB_AUTH_USERNAME=skg
|
||||
WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
WEB_AUTH_COOKIE_SECURE=true
|
||||
|
||||
# SKG AI gateway, OpenAI-compatible
|
||||
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
LLM_API_KEY=
|
||||
|
||||
@@ -32,11 +32,14 @@ cp deploy/.env.production.example deploy/.env.production
|
||||
|
||||
Fill `deploy/.env.production` with the real production keys. Keep this file out of git.
|
||||
|
||||
Create the production login file. Replace the username and password as needed:
|
||||
Create the production web login values in `deploy/.env.production`. Replace the password as needed and keep the session secret private:
|
||||
|
||||
```bash
|
||||
printf 'skg:%s\n' "$(openssl passwd -apr1 'change-this-password')" > deploy/.htpasswd
|
||||
chmod 644 deploy/.htpasswd
|
||||
WEB_AUTH_USERNAME=skg
|
||||
WEB_AUTH_PASSWORD=change-this-password
|
||||
WEB_AUTH_SESSION_SECRET=$(openssl rand -hex 32)
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
WEB_AUTH_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
Then start:
|
||||
@@ -49,7 +52,8 @@ Verify:
|
||||
|
||||
```bash
|
||||
curl -I https://marketing.skg.com
|
||||
curl https://marketing.skg.com/api/health
|
||||
curl -I https://marketing.skg.com/login/
|
||||
curl -i https://marketing.skg.com/api/health
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
@@ -64,7 +68,7 @@ docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -
|
||||
## Runtime Notes
|
||||
|
||||
- `web` is a static Next export served by Nginx.
|
||||
- `web` requires Nginx Basic Auth for the whole site and then proxies `/api/` to `skg-marketing-api:4291`; avoid the generic hostname `api` because the web container also joins the shared Coolify network.
|
||||
- `web` exposes `/login/` publicly. All workspace routes redirect to `/login/` until the FastAPI session cookie passes Nginx `auth_request`; `/api/` returns JSON 401 when unauthenticated and then proxies to `skg-marketing-api:4291` after login.
|
||||
- `api` is only on the internal project network and stores jobs under `/data/jobs`.
|
||||
- Server-side job files persist in `./data/jobs` on the VPS.
|
||||
- Large uploads are allowed up to `2g` at the Nginx proxy layer.
|
||||
|
||||
@@ -536,7 +536,7 @@
|
||||
<tr>
|
||||
<td>生产站点</td>
|
||||
<td><code>https://marketing.skg.com</code></td>
|
||||
<td>公司域名已解析到 VPS <code>76.13.31.179</code>。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 <code>web</code> 容器用 Nginx 承载静态前端、执行 Basic Auth 登录,并把 <code>/api/</code> 反代到 FastAPI。</td>
|
||||
<td>公司域名已解析到 VPS <code>76.13.31.179</code>。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 <code>web</code> 容器用 Nginx 承载静态前端;未登录访问工作台跳转 <code>/login/</code>,<code>/api/</code> 通过 <code>auth_request</code> 校验 FastAPI 会话 Cookie 后再反代。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>生产部署</td>
|
||||
@@ -591,6 +591,7 @@
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态:jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。</td></tr>
|
||||
<tr><td><code>web/app/login/page.tsx</code></td><td>生产登录页:账号密码表单、保持登录、错误/成功状态,以及参考风格库 14 的四个动画角色互动。</td></tr>
|
||||
<tr><td><code>web/components/nodes/index.tsx</code></td><td>DAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
|
||||
<tr><td><code>web/components/audio-strip.tsx</code></td><td>底部吸附音频条:可拖拽调整高度;播放原音频时移动指针,逐个高亮英文/中文字幕节点和对应波形,并在右侧固定显示按原音频时长生成的 SKG 英文产品口播和 MiniMax 随机英文配音。</td></tr>
|
||||
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。</td></tr>
|
||||
@@ -605,7 +606,7 @@
|
||||
<h3>后端核心</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端:状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、音频文案改写、MiniMax 英文配音、文件返回。</td></tr>
|
||||
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、音频文案改写、MiniMax 英文配音、文件返回。</td></tr>
|
||||
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,<code>images/</code> 存 45 张参考图。</td></tr>
|
||||
<tr><td><code>jobs/<jobId>/state.json</code></td><td>运行时状态文件,不在源码列表里,但刷新恢复依赖它。</td></tr>
|
||||
<tr><td><code>jobs/<jobId>/audio.wav</code></td><td>拆轨得到的原始音频,底部 Audio Strip 会通过只读接口拉取并在浏览器里解码成波形峰值。</td></tr>
|
||||
@@ -806,6 +807,7 @@ SubjectAsset {
|
||||
<tr><th>功能</th><th>接口</th><th>前端调用</th><th>说明</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>网页登录</td><td><code>POST /auth/login</code>、<code>GET /auth/check</code>、<code>POST /auth/logout</code></td><td><code>web/app/login/page.tsx</code>、Nginx <code>auth_request</code></td><td>登录页提交账号密码到 <code>/api/auth/login</code>,后端设置 HttpOnly 会话 Cookie;生产 Nginx 对工作台和 <code>/api/</code> 调 <code>/auth/check</code> 做统一校验,未登录页面跳 <code>/login/</code>,API 返回 JSON 401。</td></tr>
|
||||
<tr><td>历史列表</td><td><code>GET /jobs</code></td><td><code>listJobs</code></td><td>所有 job 精简列表(id/url/status/thumbnail/mtime…),按 state.json mtime 倒序。前端 URL 无 <code>?job=</code> 时拉它回填全部历史;带 <code>limit</code> 可截断。</td></tr>
|
||||
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载,停在 downloaded 等用户点解析。</td></tr>
|
||||
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4,然后同样进入下载完成状态。</td></tr>
|
||||
@@ -941,14 +943,15 @@ SubjectAsset {
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-15 · 生产站点增加登录保护</h3>
|
||||
<h3>2026-05-15 · 生产站点增加应用内登录页</h3>
|
||||
<span class="tag gray">Runtime</span>
|
||||
<span class="tag blue">Security</span>
|
||||
<span class="tag rose">UI</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>公司域名部署后任何人知道地址都能打开工作台并调用生成能力。</p>
|
||||
<p><strong>改动:</strong>在生产 <code>web</code> Nginx 容器增加 Basic Auth,整站和 <code>/api/</code> 统一要求账号密码;哈希文件挂载自服务器 <code>/opt/skg-marketing-studio/deploy/.htpasswd</code>,明文密码只保存在服务器 root 说明文件,不入库。</p>
|
||||
<p><strong>影响:</strong><code>docker-compose.prod.yml</code>、<code>deploy/nginx.conf</code>、<code>.gitignore</code>、<code>.project.json</code>、<code>RULES.md</code>、<code>docs/deploy-vps.md</code>、<code>docs/source-analysis.html</code>。</p>
|
||||
<p><strong>改动:</strong>把浏览器 Basic Auth 改为应用内登录页:前端新增 <code>web/app/login/page.tsx</code>,参考风格库 <code>14 动画角色登录</code> 做四个几何角色、鼠标视线跟随、输入 / 显示密码 / 错误 / 成功状态反馈;后端新增 <code>/auth/login</code>、<code>/auth/check</code>、<code>/auth/logout</code>,使用 HttpOnly Cookie + HMAC 会话签名;生产 Nginx 通过 <code>auth_request</code> 保护工作台和 <code>/api/</code>。</p>
|
||||
<p><strong>影响:</strong><code>api/main.py</code>、<code>web/app/login/page.tsx</code>、<code>web/app/globals.css</code>、<code>web/lib/api.ts</code>、<code>docker-compose.prod.yml</code>、<code>deploy/nginx.conf</code>、<code>deploy/.env.production.example</code>、<code>api/.env.example</code>、<code>.project.json</code>、<code>RULES.md</code>、<code>docs/deploy-vps.md</code>、<code>docs/source-analysis.html</code>。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
|
||||
@@ -124,6 +124,245 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
生产登录页 · 动画角色登录风格
|
||||
============================================================ */
|
||||
.login-page {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(135deg, #090a12 0%, #111426 48%, #07080d 100%);
|
||||
background-size: 44px 44px, 44px 44px, auto;
|
||||
}
|
||||
.login-page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(115deg, transparent 0 18%, rgba(108, 63, 245, 0.16) 18% 28%, transparent 28% 52%, rgba(255, 155, 107, 0.12) 52% 60%, transparent 60%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 28%, rgba(0, 0, 0, 0.36));
|
||||
}
|
||||
.login-page::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
||||
background-size: 100% 6px;
|
||||
opacity: 0.45;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
.login-hero {
|
||||
isolation: isolate;
|
||||
}
|
||||
.login-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(108, 63, 245, 0.25), transparent 38%),
|
||||
linear-gradient(20deg, transparent 38%, rgba(45, 45, 45, 0.8) 39% 52%, transparent 53%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 45%);
|
||||
}
|
||||
.login-character-stage {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
||||
rgba(6, 7, 13, 0.68);
|
||||
background-size: 34px 34px, 34px 34px, auto;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 30px 70px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.login-stage-grid {
|
||||
position: absolute;
|
||||
inset: 26px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.login-character {
|
||||
--character-fill: #6c3ff5;
|
||||
--character-shadow: rgba(108, 63, 245, 0.34);
|
||||
position: absolute;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.22);
|
||||
background: var(--character-fill);
|
||||
box-shadow: 0 26px 45px var(--character-shadow), inset 0 14px 24px rgba(255, 255, 255, 0.18);
|
||||
transition:
|
||||
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-radius 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background 0.25s ease;
|
||||
animation: login-float 5.8s ease-in-out infinite;
|
||||
animation-delay: calc(var(--i) * -0.55s);
|
||||
}
|
||||
.login-character--pilot {
|
||||
left: 10%;
|
||||
top: 18%;
|
||||
border-radius: 34% 54% 46% 42%;
|
||||
}
|
||||
.login-character--lens {
|
||||
--character-fill: #ff9b6b;
|
||||
--character-shadow: rgba(255, 155, 107, 0.28);
|
||||
right: 13%;
|
||||
top: 10%;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.login-character--spark {
|
||||
--character-fill: #e8d754;
|
||||
--character-shadow: rgba(232, 215, 84, 0.22);
|
||||
left: 28%;
|
||||
bottom: 12%;
|
||||
width: 106px;
|
||||
height: 106px;
|
||||
border-radius: 28% 50% 32% 52%;
|
||||
color: #111;
|
||||
}
|
||||
.login-character--keeper {
|
||||
--character-fill: #2d2d2d;
|
||||
--character-shadow: rgba(0, 0, 0, 0.5);
|
||||
right: 28%;
|
||||
bottom: 16%;
|
||||
width: 138px;
|
||||
height: 138px;
|
||||
border-radius: 46% 38% 54% 34%;
|
||||
}
|
||||
.login-character__gloss {
|
||||
position: absolute;
|
||||
left: 20%;
|
||||
top: 16%;
|
||||
width: 34%;
|
||||
height: 18%;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
.login-character__eye {
|
||||
position: absolute;
|
||||
top: 39%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.login-character--spark .login-character__eye {
|
||||
background: rgba(17, 17, 17, 0.92);
|
||||
}
|
||||
.login-character__eye::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #111;
|
||||
transform: translate(var(--eye-x), var(--eye-y));
|
||||
transition: transform 0.08s linear, opacity 0.2s ease;
|
||||
}
|
||||
.login-character--spark .login-character__eye::after {
|
||||
background: #fff;
|
||||
}
|
||||
.login-character__eye--left {
|
||||
left: 28%;
|
||||
}
|
||||
.login-character__eye--right {
|
||||
right: 28%;
|
||||
}
|
||||
.login-character__mouth {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 62%;
|
||||
width: 30px;
|
||||
height: 12px;
|
||||
border-bottom: 3px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 0 0 999px 999px;
|
||||
transform: translateX(-50%);
|
||||
transition: transform 0.2s ease, height 0.2s ease, border-radius 0.2s ease;
|
||||
}
|
||||
.login-character--spark .login-character__mouth {
|
||||
border-bottom-color: #111;
|
||||
}
|
||||
.login-character__badge {
|
||||
position: absolute;
|
||||
right: 15%;
|
||||
bottom: 14%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
.login-page[data-mood="typing"] .login-character {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
}
|
||||
.login-page[data-mood="peek"] .login-character__eye::after {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.login-page[data-mood="peek"] .login-character--keeper {
|
||||
border-radius: 50% 50% 40% 40%;
|
||||
transform: translateY(-10px) scale(1.05);
|
||||
}
|
||||
.login-page[data-mood="error"] .login-character {
|
||||
animation: login-shake 0.28s ease-in-out 2;
|
||||
}
|
||||
.login-page[data-mood="error"] .login-character__mouth {
|
||||
height: 14px;
|
||||
border-top: 3px solid rgba(255, 255, 255, 0.9);
|
||||
border-bottom: 0;
|
||||
border-radius: 999px 999px 0 0;
|
||||
transform: translate(-50%, 5px);
|
||||
}
|
||||
.login-page[data-mood="success"] .login-character {
|
||||
transform: translateY(-14px) scale(1.06);
|
||||
}
|
||||
.login-page[data-mood="success"] .login-character__mouth {
|
||||
height: 18px;
|
||||
}
|
||||
@keyframes login-float {
|
||||
0%, 100% { translate: 0 0; }
|
||||
50% { translate: 0 -12px; }
|
||||
}
|
||||
@keyframes login-shake {
|
||||
0%, 100% { translate: 0 0; }
|
||||
33% { translate: -5px 0; }
|
||||
66% { translate: 5px 0; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.login-character-stage {
|
||||
min-height: 260px;
|
||||
}
|
||||
.login-character {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
.login-character--lens,
|
||||
.login-character--spark {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
}
|
||||
.login-character--keeper {
|
||||
width: 102px;
|
||||
height: 102px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-character {
|
||||
animation: none;
|
||||
}
|
||||
.login-page[data-mood="error"] .login-character {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
画布背景:渐变 + 极光 + 颗粒
|
||||
============================================================ */
|
||||
|
||||
253
web/app/login/page.tsx
Normal file
253
web/app/login/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
"use client"
|
||||
|
||||
import type { CSSProperties, FormEvent } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LockKeyhole,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from "lucide-react"
|
||||
|
||||
type LoginStatus = "idle" | "loading" | "success"
|
||||
type LoginMood = "idle" | "typing" | "peek" | "error" | "success"
|
||||
|
||||
const CHARACTER_IDS = ["pilot", "lens", "spark", "keeper"] as const
|
||||
|
||||
export default function LoginPage() {
|
||||
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 [error, setError] = useState("")
|
||||
const [status, setStatus] = useState<LoginStatus>("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 * 5, y: nextY * 3 })
|
||||
}
|
||||
window.addEventListener("pointermove", onPointerMove)
|
||||
return () => window.removeEventListener("pointermove", onPointerMove)
|
||||
}, [])
|
||||
|
||||
const mood: LoginMood = useMemo(() => {
|
||||
if (status === "success") return "success"
|
||||
if (error) return "error"
|
||||
if (showPassword && activeField === "password") return "peek"
|
||||
if (activeField || username || password) return "typing"
|
||||
return "idle"
|
||||
}, [activeField, error, password, showPassword, status, username])
|
||||
|
||||
const disabled = status === "loading" || status === "success"
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setError("")
|
||||
if (!username.trim() || !password) {
|
||||
setError("请输入账号和密码")
|
||||
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) {
|
||||
let message = "账号或密码不正确"
|
||||
try {
|
||||
const data = await res.json()
|
||||
message = data?.detail || data?.error || message
|
||||
} catch {
|
||||
// keep default message
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
setStatus("success")
|
||||
window.setTimeout(() => {
|
||||
window.location.href = "/"
|
||||
}, 420)
|
||||
} catch (err) {
|
||||
setStatus("idle")
|
||||
setError(err instanceof Error ? err.message : "登录失败,请稍后再试")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="login-page relative min-h-screen overflow-hidden px-5 py-6 text-white sm:px-8 lg:px-10"
|
||||
data-mood={mood}
|
||||
style={
|
||||
{
|
||||
"--eye-x": `${eyeOffset.x}px`,
|
||||
"--eye-y": `${eyeOffset.y}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="relative z-10 mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-7xl items-center">
|
||||
<div className="grid w-full gap-5 lg:grid-cols-[minmax(0,1.08fr)_minmax(380px,460px)] lg:items-stretch">
|
||||
<section className="login-hero relative min-h-[470px] overflow-hidden rounded-[8px] border border-white/10 bg-black/35 p-6 shadow-2xl shadow-black/35 sm:p-8 lg:min-h-[620px]">
|
||||
<div className="relative z-10 flex h-full flex-col justify-between gap-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-[8px] border border-white/10 bg-white text-black">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white/55">SKG Marketing Studio</p>
|
||||
<h1 className="text-3xl font-semibold leading-tight text-white sm:text-4xl">营销内容工作台</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-[8px] border border-emerald-300/25 bg-emerald-300/10 px-3 py-2 text-sm text-emerald-100">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<span>生产入口已保护</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-character-stage" aria-hidden="true">
|
||||
<div className="login-stage-grid" />
|
||||
{CHARACTER_IDS.map((id, index) => (
|
||||
<div className={`login-character login-character--${id}`} key={id} style={{ "--i": index } as CSSProperties}>
|
||||
<span className="login-character__gloss" />
|
||||
<span className="login-character__eye login-character__eye--left" />
|
||||
<span className="login-character__eye login-character__eye--right" />
|
||||
<span className="login-character__mouth" />
|
||||
<span className="login-character__badge" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
["Visual", "素材"],
|
||||
["Audio", "声音"],
|
||||
["Video", "成片"],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-[8px] border border-white/10 bg-white/[0.06] px-4 py-3">
|
||||
<p className="text-xs text-white/45">{label}</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-[470px] items-center rounded-[8px] border border-white/10 bg-[#10121d]/95 p-5 shadow-2xl shadow-black/40 sm:p-8 lg:min-h-[620px]">
|
||||
<form className="w-full" onSubmit={onSubmit}>
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-[8px] bg-[#6c3ff5] text-white shadow-lg shadow-[#6c3ff5]/35">
|
||||
<LockKeyhole className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-white">登录</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-white/55">进入 SKG 营销内容工作台</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-white/70">账号</span>
|
||||
<span className="flex h-12 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#8d6cff] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#8d6cff]/30">
|
||||
<UserRound className="h-4 w-4 text-white/45" />
|
||||
<input
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
|
||||
value={username}
|
||||
disabled={disabled}
|
||||
autoComplete="username"
|
||||
placeholder="请输入账号"
|
||||
onFocus={() => setActiveField("username")}
|
||||
onBlur={() => setActiveField(null)}
|
||||
onChange={(event) => {
|
||||
setUsername(event.target.value)
|
||||
if (error) setError("")
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-white/70">密码</span>
|
||||
<span className="flex h-12 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#8d6cff] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#8d6cff]/30">
|
||||
<LockKeyhole className="h-4 w-4 text-white/45" />
|
||||
<input
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
|
||||
value={password}
|
||||
disabled={disabled}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
onFocus={() => setActiveField("password")}
|
||||
onBlur={() => setActiveField(null)}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value)
|
||||
if (error) setError("")
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="grid h-9 w-9 place-items-center rounded-[8px] text-white/55 transition hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#8d6cff]/45 disabled:opacity-50"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => setShowPassword((value) => !value)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-4">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#6c3ff5]"
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setRemember(event.target.checked)}
|
||||
/>
|
||||
<span>保持登录</span>
|
||||
</label>
|
||||
<span className="text-xs text-white/35">marketing.skg.com</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 min-h-11" aria-live="polite">
|
||||
{error ? (
|
||||
<div className="flex items-start gap-2 rounded-[8px] border border-red-400/30 bg-red-500/10 px-3 py-2 text-sm text-red-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : status === "success" ? (
|
||||
<div className="flex items-start gap-2 rounded-[8px] border border-emerald-300/30 bg-emerald-400/10 px-3 py-2 text-sm text-emerald-100">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>登录成功,正在进入工作台</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mt-2 flex h-12 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f2f0ff] focus:outline-none focus:ring-2 focus:ring-[#8d6cff]/60 disabled:cursor-wait disabled:opacity-70"
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
>
|
||||
<span>{status === "loading" ? "正在登录" : status === "success" ? "已通过" : "进入工作台"}</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -398,6 +398,7 @@ export interface Job {
|
||||
export interface BackendHealth {
|
||||
ok: boolean
|
||||
llm_configured: boolean
|
||||
auth_configured?: boolean
|
||||
base_url: string
|
||||
models?: {
|
||||
asr?: string
|
||||
|
||||
Reference in New Issue
Block a user