auto-save 2026-05-15 15:21 (+1, ~9)

This commit is contained in:
2026-05-15 15:21:20 +08:00
parent 7ee9ea2303
commit f7cc49a455
10 changed files with 540 additions and 24 deletions

View File

@@ -1,11 +1,5 @@
{ {
"entries": [ "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, "files_changed": 2,
"hash": "12daaa2", "hash": "12daaa2",
@@ -3250,6 +3244,13 @@
"type": "session-heartbeat", "type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-15 15:10 (~2)", "message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-15 15:10 (~2)",
"files_changed": 4 "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
} }
] ]
} }

View File

@@ -21,9 +21,9 @@
"type" : "api_key" "type" : "api_key"
}, },
{ {
"description" : "生产站点 Basic Auth 登录;用户名写 RULES.md密码只放服务器 /root/skg-marketing-studio-login.txtNginx 使用 deploy/.htpasswd 哈希文件", "description" : "生产网页登录;用户名写 RULES.md密码只放服务器 /root/skg-marketing-studio-login.txt后端会话密钥只放服务器 deploy/.env.production 的 WEB_AUTH_SESSION_SECRET",
"name" : "WEB_BASIC_AUTH", "name" : "WEB_LOGIN",
"storage" : "/root/skg-marketing-studio-login.txt / deploy/.htpasswd", "storage" : "/root/skg-marketing-studio-login.txt / deploy/.env.production",
"type" : "web_login" "type" : "web_login"
} }
], ],

View File

@@ -14,22 +14,22 @@
## 部署事实 ## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik - 平台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` - 主站 / 前端:`https://marketing.skg.com`
- API / 后端:`https://marketing.skg.com/api` - API / 后端:`https://marketing.skg.com/api`
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由) - 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
- 管理后台:待定 - 管理后台:待定
- 服务器目录:`/opt/skg-marketing-studio` - 服务器目录:`/opt/skg-marketing-studio`
- 生产启动:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build` - 生产启动:`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 4291Traefik 通过 `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` - 持久化目录:服务器 `./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` - 用户名:`skg`
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库) - 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明:当前是生产入口 Basic Auth数据库密码、API Key、服务器 root 密码不要写这里 - 说明:当前是生产入口应用内登录页数据库密码、API Key、服务器 root 密码不要写这里
## 元数据回写清单 ## 元数据回写清单
- 新增或变更公网地址后,必须同步更新 `.project.json.urls` - 新增或变更公网地址后,必须同步更新 `.project.json.urls`
@@ -50,6 +50,7 @@
- `MINIMAX_TTS_BASE_URL` / `MINIMAX_TTS_MODEL` / `MINIMAX_TTS_VOICE_ID`MiniMax 配音端点、模型和兜底音色配置 - `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` - `MINIMAX_TTS_VOICE_POOL`MiniMax 英文随机音色池;当前默认男声 `English_magnetic_voiced_man`、女声 `English_Upbeat_Woman`、成熟声 `English_MaturePartner`
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key只能放本地环境变量 - `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 不入库 - 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
## 规则 ## 规则

View File

@@ -2,6 +2,13 @@
LLM_BASE_URL=https://ai.skg.com/ezlink/v1 LLM_BASE_URL=https://ai.skg.com/ezlink/v1
LLM_API_KEY= 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_MODEL=whisper-1
ASR_FALLBACK_MODEL=gemini-2.5-flash ASR_FALLBACK_MODEL=gemini-2.5-flash

View File

@@ -7,6 +7,13 @@ KEYFRAME_COUNT=12
CORS_ORIGINS=https://marketing.skg.com CORS_ORIGINS=https://marketing.skg.com
API_PORT=4291 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 # SKG AI gateway, OpenAI-compatible
LLM_BASE_URL=https://ai.skg.com/ezlink/v1 LLM_BASE_URL=https://ai.skg.com/ezlink/v1
LLM_API_KEY= LLM_API_KEY=

View File

@@ -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. 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 ```bash
printf 'skg:%s\n' "$(openssl passwd -apr1 'change-this-password')" > deploy/.htpasswd WEB_AUTH_USERNAME=skg
chmod 644 deploy/.htpasswd 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: Then start:
@@ -49,7 +52,8 @@ Verify:
```bash ```bash
curl -I https://marketing.skg.com 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 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 ## Runtime Notes
- `web` is a static Next export served by Nginx. - `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`. - `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. - Server-side job files persist in `./data/jobs` on the VPS.
- Large uploads are allowed up to `2g` at the Nginx proxy layer. - Large uploads are allowed up to `2g` at the Nginx proxy layer.

View File

@@ -536,7 +536,7 @@
<tr> <tr>
<td>生产站点</td> <td>生产站点</td>
<td><code>https://marketing.skg.com</code></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>
<tr> <tr>
<td>生产部署</td> <td>生产部署</td>
@@ -591,6 +591,7 @@
<table> <table>
<tbody> <tbody>
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。</td></tr> <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/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/audio-strip.tsx</code></td><td>底部吸附音频条:可拖拽调整高度;播放原音频时移动指针,逐个高亮英文/中文字幕节点和对应波形,并在右侧固定显示按原音频时长生成的 SKG 英文产品口播和 MiniMax 随机英文配音。</td></tr>
<tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。</td></tr> <tr><td><code>web/components/lightbox.tsx</code></td><td>关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。</td></tr>
@@ -605,7 +606,7 @@
<h3>后端核心</h3> <h3>后端核心</h3>
<table> <table>
<tbody> <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>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/&lt;jobId&gt;/state.json</code></td><td>运行时状态文件,不在源码列表里,但刷新恢复依赖它。</td></tr> <tr><td><code>jobs/&lt;jobId&gt;/state.json</code></td><td>运行时状态文件,不在源码列表里,但刷新恢复依赖它。</td></tr>
<tr><td><code>jobs/&lt;jobId&gt;/audio.wav</code></td><td>拆轨得到的原始音频,底部 Audio Strip 会通过只读接口拉取并在浏览器里解码成波形峰值。</td></tr> <tr><td><code>jobs/&lt;jobId&gt;/audio.wav</code></td><td>拆轨得到的原始音频,底部 Audio Strip 会通过只读接口拉取并在浏览器里解码成波形峰值。</td></tr>
@@ -806,6 +807,7 @@ SubjectAsset {
<tr><th>功能</th><th>接口</th><th>前端调用</th><th>说明</th></tr> <tr><th>功能</th><th>接口</th><th>前端调用</th><th>说明</th></tr>
</thead> </thead>
<tbody> <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>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</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> <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"> <div class="changelog">
<article class="change"> <article class="change">
<header> <header>
<h3>2026-05-15 · 生产站点增加登录保护</h3> <h3>2026-05-15 · 生产站点增加应用内登录页</h3>
<span class="tag gray">Runtime</span> <span class="tag gray">Runtime</span>
<span class="tag blue">Security</span> <span class="tag blue">Security</span>
<span class="tag rose">UI</span>
</header> </header>
<div class="body"> <div class="body">
<p><strong>问题:</strong>公司域名部署后任何人知道地址都能打开工作台并调用生成能力。</p> <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>把浏览器 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>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><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> </div>
</article> </article>
<article class="change"> <article class="change">

View File

@@ -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
View 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>
)
}

View File

@@ -398,6 +398,7 @@ export interface Job {
export interface BackendHealth { export interface BackendHealth {
ok: boolean ok: boolean
llm_configured: boolean llm_configured: boolean
auth_configured?: boolean
base_url: string base_url: string
models?: { models?: {
asr?: string asr?: string