auto-save 2026-05-15 15:21 (+1, ~9)
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@
|
|||||||
"type" : "api_key"
|
"type" : "api_key"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description" : "生产站点 Basic Auth 登录;用户名写 RULES.md,密码只放服务器 /root/skg-marketing-studio-login.txt,Nginx 使用 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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
11
RULES.md
11
RULES.md
@@ -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 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`
|
- 持久化目录:服务器 `./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 不入库
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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/<jobId>/state.json</code></td><td>运行时状态文件,不在源码列表里,但刷新恢复依赖它。</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>
|
<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>
|
<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">
|
||||||
|
|||||||
@@ -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 {
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user