diff --git a/.memory/worklog.json b/.memory/worklog.json
index ac6040b..27a70e9 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -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
}
]
}
diff --git a/.project.json b/.project.json
index 7c9884f..ad826c7 100644
--- a/.project.json
+++ b/.project.json
@@ -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"
}
],
diff --git a/RULES.md b/RULES.md
index 8cd3e48..74048b3 100644
--- a/RULES.md
+++ b/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 不入库
## 规则
diff --git a/api/.env.example b/api/.env.example
index 7dba4ed..46ca140 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -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
diff --git a/deploy/.env.production.example b/deploy/.env.production.example
index 08f219b..1a63f48 100644
--- a/deploy/.env.production.example
+++ b/deploy/.env.production.example
@@ -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=
diff --git a/docs/deploy-vps.md b/docs/deploy-vps.md
index 200d686..ce11a3a 100644
--- a/docs/deploy-vps.md
+++ b/docs/deploy-vps.md
@@ -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.
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index a88598c..68856b1 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -536,7 +536,7 @@
| 生产站点 |
https://marketing.skg.com |
- 公司域名已解析到 VPS 76.13.31.179。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 web 容器用 Nginx 承载静态前端、执行 Basic Auth 登录,并把 /api/ 反代到 FastAPI。 |
+ 公司域名已解析到 VPS 76.13.31.179。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 web 容器用 Nginx 承载静态前端;未登录访问工作台跳转 /login/,/api/ 通过 auth_request 校验 FastAPI 会话 Cookie 后再反代。 |
| 生产部署 |
@@ -591,6 +591,7 @@
web/app/page.tsx | 产品工作台主状态:jobs、activeJobId、按 job 隔离的 selectedFrames/详情面板状态、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板。 |
+ web/app/login/page.tsx | 生产登录页:账号密码表单、保持登录、错误/成功状态,以及参考风格库 14 的四个动画角色互动。 |
web/components/nodes/index.tsx | DAG 节点定义:Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel;旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。 |
web/components/audio-strip.tsx | 底部吸附音频条:可拖拽调整高度;播放原音频时移动指针,逐个高亮英文/中文字幕节点和对应波形,并在右侧固定显示按原音频时长生成的 SKG 英文产品口播和 MiniMax 随机英文配音。 |
web/components/lightbox.tsx | 关键帧素材准备面板:清洗、统一主体候选、参考帧网格、六张主体重绘图、每帧去主体场景图、纵向 6 行产品融合镜头工作表和审核。 |
@@ -605,7 +606,7 @@
后端核心
- api/main.py | FastAPI 单文件后端:状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、音频文案改写、MiniMax 英文配音、文件返回。 |
+ api/main.py | FastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、音频文案改写、MiniMax 英文配音、文件返回。 |
api/product_library/skg-products | 内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,images/ 存 45 张参考图。 |
jobs/<jobId>/state.json | 运行时状态文件,不在源码列表里,但刷新恢复依赖它。 |
jobs/<jobId>/audio.wav | 拆轨得到的原始音频,底部 Audio Strip 会通过只读接口拉取并在浏览器里解码成波形峰值。 |
@@ -806,6 +807,7 @@ SubjectAsset {
| 功能 | 接口 | 前端调用 | 说明 |
+ | 网页登录 | POST /auth/login、GET /auth/check、POST /auth/logout | web/app/login/page.tsx、Nginx auth_request | 登录页提交账号密码到 /api/auth/login,后端设置 HttpOnly 会话 Cookie;生产 Nginx 对工作台和 /api/ 调 /auth/check 做统一校验,未登录页面跳 /login/,API 返回 JSON 401。 |
| 历史列表 | GET /jobs | listJobs | 所有 job 精简列表(id/url/status/thumbnail/mtime…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填全部历史;带 limit 可截断。 |
| 创建任务 | POST /jobs | createJob | 提交 TK 链接,后台开始下载,停在 downloaded 等用户点解析。 |
| 上传视频 | POST /jobs/upload | uploadJob | 保存 source.mp4,然后同样进入下载完成状态。 |
@@ -941,14 +943,15 @@ SubjectAsset {
- 2026-05-15 · 生产站点增加登录保护
+ 2026-05-15 · 生产站点增加应用内登录页
Runtime
Security
+ UI
问题:公司域名部署后任何人知道地址都能打开工作台并调用生成能力。
-
改动:在生产 web Nginx 容器增加 Basic Auth,整站和 /api/ 统一要求账号密码;哈希文件挂载自服务器 /opt/skg-marketing-studio/deploy/.htpasswd,明文密码只保存在服务器 root 说明文件,不入库。
-
影响:docker-compose.prod.yml、deploy/nginx.conf、.gitignore、.project.json、RULES.md、docs/deploy-vps.md、docs/source-analysis.html。
+
改动:把浏览器 Basic Auth 改为应用内登录页:前端新增 web/app/login/page.tsx,参考风格库 14 动画角色登录 做四个几何角色、鼠标视线跟随、输入 / 显示密码 / 错误 / 成功状态反馈;后端新增 /auth/login、/auth/check、/auth/logout,使用 HttpOnly Cookie + HMAC 会话签名;生产 Nginx 通过 auth_request 保护工作台和 /api/。
+
影响:api/main.py、web/app/login/page.tsx、web/app/globals.css、web/lib/api.ts、docker-compose.prod.yml、deploy/nginx.conf、deploy/.env.production.example、api/.env.example、.project.json、RULES.md、docs/deploy-vps.md、docs/source-analysis.html。
diff --git a/web/app/globals.css b/web/app/globals.css
index ea5bbf9..3acc648 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -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;
+ }
+}
+
/* ============================================================
画布背景:渐变 + 极光 + 颗粒
============================================================ */
diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx
new file mode 100644
index 0000000..762f1a4
--- /dev/null
+++ b/web/app/login/page.tsx
@@ -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("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) {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
SKG Marketing Studio
+
营销内容工作台
+
+
+
+
+ 生产入口已保护
+
+
+
+
+
+ {CHARACTER_IDS.map((id, index) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+ {[
+ ["Visual", "素材"],
+ ["Audio", "声音"],
+ ["Video", "成片"],
+ ].map(([label, value]) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 6ddcfbc..e55c25a 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -398,6 +398,7 @@ export interface Job {
export interface BackendHealth {
ok: boolean
llm_configured: boolean
+ auth_configured?: boolean
base_url: string
models?: {
asr?: string