Compare commits
43 Commits
f1137cc006
...
backup/pre
| Author | SHA1 | Date | |
|---|---|---|---|
| 04a822ac79 | |||
| 90dde14ac3 | |||
| 3146266383 | |||
| 0e55945352 | |||
| d551c45006 | |||
| 0d5c32630f | |||
| 7ae92ae4d9 | |||
| 642793500f | |||
| eb4347a843 | |||
| 4efb2ce456 | |||
| cc12d7c6a7 | |||
| 77d23a06b3 | |||
| 775ad79661 | |||
| a3ddb05424 | |||
| 02a9999d8c | |||
| b6fec10371 | |||
| 7bb4f3ea9f | |||
| b82dad4aa8 | |||
| 68ecc8b97b | |||
| 8458dac4bf | |||
| 5c47ea37c9 | |||
| 36da23beb2 | |||
| a48c2965d9 | |||
| d83e56169d | |||
| 8421af2af8 | |||
| f2655e1418 | |||
| def4900c1d | |||
| c805012adc | |||
| 536b4d7f59 | |||
| 1f193e95f3 | |||
| 6597db312b | |||
| dbedabaae4 | |||
| 2b842fd106 | |||
| 9c05e0bd6e | |||
| ab31a98383 | |||
| 39b9d211aa | |||
| 215987aab6 | |||
| af7622586c | |||
| e1e9bf8ca1 | |||
| fc06816483 | |||
| 45b25d01f1 | |||
| 06f3eb0504 | |||
| 54f159b6ef |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,7 @@ __pycache__/
|
||||
deploy/.env.production
|
||||
deploy/.htpasswd
|
||||
secrets/
|
||||
.backups/
|
||||
|
||||
# api
|
||||
api/.venv/
|
||||
@@ -26,6 +27,7 @@ asset_library/*
|
||||
prompt_library/*
|
||||
!prompt_library/.gitkeep
|
||||
_trash/
|
||||
output/
|
||||
|
||||
# web
|
||||
web/.next/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 项目接力
|
||||
|
||||
- 生成时间:May 20, 2026 at 16:25
|
||||
- 生成时间:May 23, 2026 at 23:37
|
||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 状态:active
|
||||
@@ -9,7 +9,7 @@
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||
- Codex:019e3db1-012e-7163-bc78-acf7cde326e7 · 时间未知
|
||||
- Codex:019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
|
||||
## Claude 最近会话
|
||||
@@ -92,27 +92,39 @@
|
||||
|
||||
## Codex 最近会话
|
||||
|
||||
- Session ID:019e3db1-012e-7163-bc78-acf7cde326e7
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/19/rollout-2026-05-19T08-44-30-019e3db1-012e-7163-bc78-acf7cde326e7.jsonl
|
||||
- Session ID:019e4d33-ad65-7673-934e-815226984ea6
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/22/rollout-2026-05-22T09-01-32-019e4d33-ad65-7673-934e-815226984ea6.jsonl
|
||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 分支:main
|
||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||
|
||||
### 最近用户要求
|
||||
|
||||
- 我之前的案例呢 我更新一次就清空了么
|
||||
- 怎么避免这样的事情出现,我都快炸了
|
||||
- 我要开新的窗口 继续这个项目了 你帮我打开吧
|
||||
- 音频解析 也需需要有中文识别 和多语言识别
|
||||
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
|
||||
- 分镜那边
|
||||
|
||||
### Codex 最近回复
|
||||
|
||||
> 这个旧路径不存在,我查一下当前机器上工作看板实际装在哪里,然后打开它。
|
||||
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说。
|
||||
|
||||
> 已打开工作看板。
|
||||
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本。
|
||||
>
|
||||
> 新窗口继续这个项目时,项目根目录用:
|
||||
> 当前链路是:
|
||||
>
|
||||
> `/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证`
|
||||
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像。
|
||||
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
|
||||
> 3. 每行会先自动套默认三字段:
|
||||
> - `文案`
|
||||
> - `场景一句话`
|
||||
> - `人物 + 产品 + 动作`
|
||||
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta,不是每句都先让大模型深度理解原文后重写。
|
||||
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
|
||||
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
|
||||
>
|
||||
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
|
||||
>
|
||||
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案,那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
|
||||
|
||||
## Cursor 最近会话
|
||||
|
||||
@@ -121,8 +133,8 @@
|
||||
## 当前仓库状态
|
||||
|
||||
- 当前分支:main
|
||||
- 未提交变更:8 项
|
||||
- 最近提交:docs: record image timeout deployment
|
||||
- 未提交变更:1 项
|
||||
- 最近提交:docs: record multilingual asr deployment
|
||||
- 变更文件:
|
||||
- M .memory/worklog.json
|
||||
|
||||
|
||||
4160
.memory/worklog.json
4160
.memory/worklog.json
File diff suppressed because it is too large
Load Diff
@@ -33,15 +33,21 @@
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "生产网页登录;用户名写 RULES.md,密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"description" : "生产网页登录备用账号;飞书免登录为主路径,备用账号密码只放服务器 \/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"
|
||||
},
|
||||
{
|
||||
"description" : "飞书免登录 OAuth 应用配置;App ID 和 App Secret 只放服务器 deploy\/.env.production,本地开发放 api\/.env,不入库;回调地址为 https:\/\/marketing.skg.com\/api\/auth\/feishu\/callback",
|
||||
"name" : "FEISHU_OAUTH",
|
||||
"storage" : "api\/.env \/ deploy\/.env.production \/ 飞书开放平台",
|
||||
"type" : "oauth_app"
|
||||
}
|
||||
],
|
||||
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
|
||||
"kind" : "app",
|
||||
"name" : "SKG Marketing Studio \/ SKG 营销内容工作台",
|
||||
"name" : "SKG 营销内容工作台",
|
||||
"ownership" : "company",
|
||||
"pin_order" : 1778664997,
|
||||
"pinned" : true,
|
||||
@@ -58,10 +64,10 @@
|
||||
}
|
||||
],
|
||||
"quick_login" : {
|
||||
"label" : "SKG Marketing Studio \/ SKG 营销内容工作台",
|
||||
"password" : "c413cdc5bbbf2ca042",
|
||||
"url" : "https:\/\/marketing.skg.com",
|
||||
"username" : "skg"
|
||||
"label" : "SKG 营销内容工作台",
|
||||
"password" : "",
|
||||
"url" : "https:\/\/marketing.skg.com\/login\/",
|
||||
"username" : "飞书免登录;备用账号见 credentials.WEB_LOGIN"
|
||||
},
|
||||
"stack" : [
|
||||
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
|
||||
@@ -78,6 +84,11 @@
|
||||
"type" : "backend",
|
||||
"url" : "https:\/\/marketing.skg.com\/api"
|
||||
},
|
||||
{
|
||||
"label" : "agent-cut-preview",
|
||||
"type" : "app",
|
||||
"url" : "http:\/\/2.24.28.41:4290\/agent\/"
|
||||
},
|
||||
{
|
||||
"label" : "git",
|
||||
"type" : "repo",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SKG AI 素材管线 - TK 二创验证 Agent Rules
|
||||
# SKG 营销内容工作台 Agent Rules
|
||||
|
||||
## Must Read First
|
||||
|
||||
|
||||
40
RULES.md
40
RULES.md
@@ -1,4 +1,4 @@
|
||||
# SKG AI 素材管线 - TK 二创验证
|
||||
# SKG 营销内容工作台
|
||||
|
||||
## 启动
|
||||
- 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`)
|
||||
@@ -11,11 +11,22 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台以 1800x1000 为基准操作画布,不同显示器或浏览器宽度下保持同一框架,并按常见桌面宽度落到预设缩放档位(0.72/0.76/0.8/0.86/0.92/1/1.06/1.16/1.24/1.34/1.48/1.6),保留适度左右呼吸感,缩放后高度小于视口时上下居中,必要时允许纵向滚动,不通过 `xl/2xl` 断点重排核心操作区。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只显示当前生成要求摘要、保留元素和收起记录计数,不展示重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后必须弹窗确认,用户点确认才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
- 当前产品方向(2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路自动识别中文、英文和其他多语言原音频文案/字幕,统一补齐中文镜像,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
- 发布状态:已部署并验证(2026-05-20,主体元素按套图文件夹分组展示,主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话、英文 prompt 弹窗确认后才触发主体套图生成;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||
- Agent Cut 独立预览服务器:`2.24.28.41`(Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`,Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
|
||||
- Agent Cut 独立预览验证(2026-05-21):已在 `2.24.28.41` 的 `/opt/skg-marketing-studio` 用 `docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200,`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true` 且 `auth_configured:true`。
|
||||
- 发布状态:已部署并验证(2026-05-20,主体元素按套图文件夹分组展示,主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;工作台外层取消 1800x1000 固定画布和应用层 `zoom` 缩放,改为正常流式桌面容器,最低操作宽度 1280px;源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;转换层不再固定 640px 长高,按内容自然高度显示,仅以 560px 最大高度兜底内部滚动;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||
- 最近部署验证(2026-05-22):`6427935` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260522012756.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`)。部署后已把生产私有 `deploy/.env.production` 明确固定为多语言本地 ASR 路径并重启 API:`ASR_LANGUAGE=auto`、`FASTER_WHISPER_MODEL=base`、`ASR_REMOTE_ENABLED=false`、`ASR_LOCAL_FALLBACK_ENABLED=true`、`ASR_AUDIO_FALLBACK_ENABLED=false`;复验 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过,容器内 `/health` 确认 `asr_language=auto`、`faster_whisper=base`。
|
||||
- 最近部署验证(2026-05-21):`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302`、`web:/login/ 200`、`web:/_next/does-not-exist.js 404`、`web:/api/health 401`、`api:health ok`、`api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台` 和 `信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。
|
||||
- 最近部署验证(2026-05-20):`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]` 和 `max-w-[1920px]`,未再命中旧的 `h-[1000px]`、`w-[1800px]`、`BOARD_SCALE_PRESETS` 或 `boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
|
||||
- 最近部署验证(2026-05-20):`2b842fd` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520145223.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/743b82648dfa9db9.js` 已包含 `h-32`、`maxHeight:560`、`提示词就绪` 和 `确认生成`,且未再命中旧的 `height:640` / `h-40`;对应转换层取消固定长高,生成要求输入区回到 128px,底部仍由发送区主按钮确认生成。
|
||||
- 最近部署验证(2026-05-20):`ab31a98` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520144227.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/5bbecb6cf31316cb.js` 已包含 `h-40`、`提示词就绪` 和 `确认生成`,对应生成要求输入框加高到 160px,出图提示词生成后不再自动弹窗,底部主按钮直接切换为确认生成。
|
||||
- 最近部署验证(2026-05-20):`215987a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142849.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/54e1ee55c5019be8.js` 已包含 `height:640`,对应转换层固定高度从 560px 扩到 640px。
|
||||
- 最近部署验证(2026-05-20):`e1e9bf8` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142145.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测生成要求 composer:文本输入区实际高约 119px,张数控件和发送按钮实际高约 42px,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-expanded.png`。
|
||||
- 最近部署验证(2026-05-20):`45b25d0` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520140706.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:生成要求区不再渲染“当前要求”、“提示词已生成”和“对话记录已收起”摘要,保留元素副本也已移除;该区只保留文本输入、张数控件和发送按钮,页面无客户端异常,验证截图 `/tmp/skg-generation-composer-simplified.png`。
|
||||
- 最近部署验证(2026-05-20):`54f159b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520135509.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:转换层和参考帧池共用高度从 500px 拉到 560px,转换层内部改为 gap 堆叠并让主要板块 `shrink-0`,超出由转换层自身滚动承接;页面无客户端异常,验证截图 `/tmp/skg-conversion-stretched.png`。
|
||||
- 最近部署验证(2026-05-20):`d1e2b17` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520134529.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测转换层:内嵌“待确认提示词”卡和黑色“确认并生成 N 张”按钮均不再渲染,页面无客户端异常;有待确认 prompt 时只在“生成要求”标题右侧显示小型“待确认 · N 张”入口,验证截图 `/tmp/skg-conversion-no-inline-confirm.png`。
|
||||
- 最近部署验证(2026-05-20):`caa7b73` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520132820.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 以 2048x1060 复测:页面无客户端异常,源视频工作区已撤销“布局调节”按钮和 `localStorage["skg-source-workspace-layout:v1"]` 布局读写,固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 270px、参考帧池 140px、转换层 500px 内部滚动、主体空态 78px;验证截图 `/tmp/skg-layout-fixed-no-tuning.png`。
|
||||
- 最近部署验证(2026-05-20):`0db265f` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520131649.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后 Playwright 打开历史任务并展开“布局调节”:面板显示左列宽、视频高、时间轴高、参考池宽、转换层高、主体空态 6 个滑杆,调参值写入 `localStorage["skg-source-workspace-layout:v1"]`,供用户先在线试比例再固化默认值。
|
||||
@@ -49,19 +60,21 @@
|
||||
- 服务器目录:`/opt/skg-marketing-studio`
|
||||
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
||||
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
|
||||
- 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`;Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。
|
||||
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`,`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||
- Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx,不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/`、`/_next/`、`/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。
|
||||
- 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内 `faster-whisper tiny.en` 真实转写,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`。
|
||||
- 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
|
||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library`、`./data/prompt_library` 和 `./data/_trash`
|
||||
- TikTok 下载登录态:公开视频默认不带 cookies 直接下载,生产环境变量必须显式保持 `YTDLP_COOKIES_FILE=`、`YTDLP_COOKIES_FROM_BROWSER=` 为空,防止容器读取不存在的浏览器 cookies。只有 TikTok 明确要求登录态时,才使用服务器私有 cookies 文件 `./secrets/tiktok_cookies.txt` 挂载到 API 容器 `/run/secrets/tiktok_cookies.txt` 并配置 `YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt`;`yt-dlp` 会在任务结束时回写 cookies,因此不要把该挂载设为只读;不要使用云端浏览器读取方案,也不要把 cookies 入库。生产容器严禁使用 `YTDLP_COOKIES_FROM_BROWSER=chrome`。
|
||||
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
|
||||
- 登录凭证:生产入口以飞书免登录为主;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。原账号密码登录保留为备用入口,用户名写下方快捷登录,密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,`WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务只对备用账号可见,飞书用户互不可见。
|
||||
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude:`.git`、`.memory`、`.logs`、`.pids`、`data`、`jobs`、`secrets`、`api/jobs`、`api/.env`、`api/.env.local`、`api/.env.production`、`deploy/.env.production`、`web/node_modules`、`web/.next`、`web/out`。不要把本地 `api/.env` 或 `deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
|
||||
|
||||
## 快捷登录
|
||||
- 登录地址:`https://marketing.skg.com/login/`
|
||||
- 用户名:`skg`
|
||||
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
|
||||
- 说明:当前是生产入口应用内登录页;数据库密码、API Key、服务器 root 密码不要写这里
|
||||
- 主路径:飞书免登录
|
||||
- 备用用户名:`skg`
|
||||
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
|
||||
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
|
||||
|
||||
## 元数据回写清单
|
||||
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
|
||||
@@ -83,11 +96,11 @@
|
||||
- `LLM_BASE_URL` / `LLM_API_KEY`:OpenAI 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用
|
||||
- `ASR_BASE_URL` / `ASR_API_KEY`:OpenAI Audio Transcriptions 兼容网关,用于上传 `audio.wav` 做真实转写;未配置 `ASR_API_KEY` 时复用 `LLM_API_KEY`,生产默认指向 `https://ai.skg.com/azure/v1`
|
||||
- `ASR_MODEL`:OpenAI Audio Transcriptions 音频转写模型;微软通道使用 Azure OpenAI 部署名 `gpt-4o-transcribe`,如果 Azure 侧实际部署名不同必须同步改这里
|
||||
- `ASR_LANGUAGE`:远端 ASR 的输入语言提示,默认 `en`;微软官方说明指定 ISO-639-1 语言可改善准确率和延迟。
|
||||
- `ASR_LANGUAGE`:远端和本地 ASR 的可选输入语言提示,默认空值/`auto`,由模型自动识别中文、英文和其他多语言;只有明确知道素材固定语种时才填写 ISO-639-1 代码强制识别。
|
||||
- `ASR_REMOTE_ENABLED`:是否启用远端 OpenAI Audio Transcriptions;微软 ASR 验收时必须为 `true`。当前生产因 `https://ai.skg.com/azure/v1` 下 `gpt-4o-transcribe` 返回 `DeploymentNotFound`,临时设为 `false`,直接走容器内 `faster-whisper`,等真实 Azure deployment 名补齐后再恢复。
|
||||
- `ASR_LOCAL_FALLBACK_ENABLED`:是否允许远端 ASR 失败后落到本机 / 容器内 ASR;当前生产为 `true`,复制本地成功路径的“本机真实转写”策略,云端用 CPU 版 `faster-whisper` 替代本机 Mac 的 `mlx_whisper`。
|
||||
- `ASR_AUDIO_FALLBACK_ENABLED`:是否允许远端和本机 ASR 失败后落到多模态音频兜底;生产微软 ASR 验收设为 `false`,避免静默使用 Gemini 音频
|
||||
- `FASTER_WHISPER_MODEL` / `FASTER_WHISPER_DEVICE` / `FASTER_WHISPER_COMPUTE_TYPE`:容器内本地 ASR 兜底,仅在 `ASR_LOCAL_FALLBACK_ENABLED=true` 时启用
|
||||
- `FASTER_WHISPER_MODEL` / `FASTER_WHISPER_DEVICE` / `FASTER_WHISPER_COMPUTE_TYPE`:容器内本地 ASR 兜底,仅在 `ASR_LOCAL_FALLBACK_ENABLED=true` 时启用;默认用多语言 `base`,不要改回 `*.en` 英文专用模型,否则中文和多语言识别会退化。
|
||||
- `ASR_FALLBACK_MODEL`:多模态音频兜底模型,仅在 `ASR_AUDIO_FALLBACK_ENABLED=true` 时用于兜底或音频画像,默认 `gemini-2.5-flash`;如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
|
||||
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 翻译 / 音频分析单次请求超时;当前生产本地转写模式设为 45 秒,微软 ASR 重新启用时可按素材长度提高。
|
||||
- `LOCAL_ASR_BIN` / `LOCAL_ASR_MODEL` / `LOCAL_ASR_TIMEOUT_SECONDS`:本机 ASR 兜底,默认使用 `/opt/homebrew/bin/mlx_whisper` + `mlx-community/whisper-tiny`,用于当前 SKG 网关 `/audio/transcriptions` 不可用时生成真实逐句时间轴
|
||||
@@ -109,7 +122,12 @@
|
||||
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
|
||||
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`:Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
|
||||
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key,只能放本地环境变量
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
|
||||
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
|
||||
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。
|
||||
- `FEISHU_REDIRECT_URI`:飞书 OAuth 回调地址,生产固定为 `https://marketing.skg.com/api/auth/feishu/callback`。
|
||||
- `FEISHU_OAUTH_SCOPE`:飞书 OAuth 授权范围;默认空值,按飞书应用后台已开权限执行。
|
||||
- `FEISHU_ALLOWED_EMAIL_DOMAINS` / `FEISHU_ALLOWED_EMAILS` / `FEISHU_ALLOWED_TENANT_KEYS`:可选飞书账号白名单;留空时由飞书应用可见范围控制。
|
||||
- `AUTH_DATA_ISOLATION_ENABLED`:多用户数据隔离开关,生产保持 `true`;新建 `Job` / `AgentRun` 会写入当前登录用户 owner,列表和详情访问只返回本人数据。
|
||||
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
|
||||
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
|
||||
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`;网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt`
|
||||
|
||||
@@ -8,6 +8,16 @@ WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
WEB_AUTH_COOKIE_SECURE=false
|
||||
AUTH_DATA_ISOLATION_ENABLED=true
|
||||
|
||||
# 飞书免登录(OAuth)。生产回调地址需同步配置到飞书开放平台应用安全设置。
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
|
||||
FEISHU_OAUTH_SCOPE=
|
||||
FEISHU_ALLOWED_EMAIL_DOMAINS=
|
||||
FEISHU_ALLOWED_EMAILS=
|
||||
FEISHU_ALLOWED_TENANT_KEYS=
|
||||
|
||||
# 模型分工
|
||||
ASR_MODEL=whisper-1
|
||||
|
||||
@@ -35,6 +35,6 @@ uvicorn main:app --host 127.0.0.1 --port 4291
|
||||
|
||||
- `ffmpeg` 系统二进制(拆轨 / 抽帧)
|
||||
- `yt-dlp` 系统二进制(也可走 Python 包)
|
||||
- OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写 / 视觉 brief);远端 `whisper-1` 失败后先走本机 `mlx_whisper`,再用 `ASR_FALLBACK_MODEL` 走 Gemini 多模态音频识别,后端会拒绝疑似假字幕或覆盖率过低的时间轴
|
||||
- OpenAI 兼容 LLM 网关(ASR / 翻译 / 文案改写 / 视觉 brief);ASR 默认自动识别中文、英文和其他多语言,远端失败后先走容器内多语言 `faster-whisper` / 本机 `mlx_whisper`,再按开关用 `ASR_FALLBACK_MODEL` 走多模态音频识别,后端会拒绝疑似假字幕或覆盖率过低的时间轴
|
||||
- GPT 图片网关(当前所有生图 / 修图 / 产品视角识别 / 主体资产 / 首尾帧都强制使用 `gpt-image-2`,不做其他图片模型 fallback)
|
||||
- Azure OpenAI TTS(后续新配音阶段使用 `AZURE_OPENAI_API_KEY`;默认模型 `gpt-4o-mini-tts`,按 `AZURE_TTS_PATHS` 依次尝试语音路径)
|
||||
|
||||
3
api/asset_library/index.json
Normal file
3
api/asset_library/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
1141
api/main.py
1141
api/main.py
File diff suppressed because it is too large
Load Diff
3
api/prompt_library/index.json
Normal file
3
api/prompt_library/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
@@ -15,6 +15,17 @@ WEB_AUTH_PASSWORD=
|
||||
WEB_AUTH_SESSION_SECRET=
|
||||
WEB_AUTH_COOKIE_NAME=skg_marketing_session
|
||||
WEB_AUTH_COOKIE_SECURE=true
|
||||
AUTH_DATA_ISOLATION_ENABLED=true
|
||||
|
||||
# Feishu OAuth login. Register this callback in the Feishu developer console:
|
||||
# https://marketing.skg.com/api/auth/feishu/callback
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
|
||||
FEISHU_OAUTH_SCOPE=
|
||||
FEISHU_ALLOWED_EMAIL_DOMAINS=
|
||||
FEISHU_ALLOWED_EMAILS=
|
||||
FEISHU_ALLOWED_TENANT_KEYS=
|
||||
|
||||
# SKG AI gateway, OpenAI-compatible
|
||||
LLM_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
|
||||
@@ -20,6 +20,20 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/auth/ {
|
||||
proxy_pass http://skg-marketing-api:4291/auth/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
|
||||
location = /api/auth/login {
|
||||
proxy_pass http://skg-marketing-api:4291/auth/login;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -10,11 +10,13 @@ services:
|
||||
- ./deploy/.env.production
|
||||
environment:
|
||||
JOBS_DIR: /data/jobs
|
||||
AGENT_RUNS_DIR: /data/agent_runs
|
||||
ASSET_LIBRARY_DIR: /data/asset_library
|
||||
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||
CORS_ORIGINS: https://marketing.skg.com
|
||||
volumes:
|
||||
- ./data/jobs:/data/jobs
|
||||
- ./data/agent_runs:/data/agent_runs
|
||||
- ./data/asset_library:/data/asset_library
|
||||
- ./data/prompt_library:/data/prompt_library
|
||||
- ./data/_trash:/data/_trash
|
||||
|
||||
47
docker-compose.standalone.yml
Normal file
47
docker-compose.standalone.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: skg-agent-cut
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
container_name: skg-agent-api
|
||||
env_file:
|
||||
- ./deploy/.env.production
|
||||
environment:
|
||||
JOBS_DIR: /data/jobs
|
||||
AGENT_RUNS_DIR: /data/agent_runs
|
||||
ASSET_LIBRARY_DIR: /data/asset_library
|
||||
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||
CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290
|
||||
volumes:
|
||||
- ./data/jobs:/data/jobs
|
||||
- ./data/agent_runs:/data/agent_runs
|
||||
- ./data/asset_library:/data/asset_library
|
||||
- ./data/prompt_library:/data/prompt_library
|
||||
- ./data/_trash:/data/_trash
|
||||
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
skg-agent-internal:
|
||||
aliases:
|
||||
- skg-marketing-api
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
NEXT_PUBLIC_API_BASE: /api
|
||||
container_name: skg-agent-web
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "0.0.0.0:4290:80"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- skg-agent-internal
|
||||
|
||||
networks:
|
||||
skg-agent-internal:
|
||||
name: skg-agent-internal
|
||||
File diff suppressed because one or more lines are too long
332
web/app/agent/page.tsx
Normal file
332
web/app/agent/page.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
Film,
|
||||
ImagePlus,
|
||||
Link2,
|
||||
Loader2,
|
||||
Play,
|
||||
RotateCcw,
|
||||
TerminalSquare,
|
||||
Upload,
|
||||
} from "lucide-react"
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"
|
||||
|
||||
type AgentRunLog = {
|
||||
ts: number
|
||||
level: "info" | "warn" | "error"
|
||||
message: string
|
||||
}
|
||||
|
||||
type AgentRun = {
|
||||
id: string
|
||||
job_id: string
|
||||
status: "draft" | "queued" | "executing" | "reviewing" | "completed" | "failed"
|
||||
stage: string
|
||||
progress: number
|
||||
logs: AgentRunLog[]
|
||||
video_ids: string[]
|
||||
final_video_url: string
|
||||
contact_sheet_url: string
|
||||
error: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
const STAGES = [
|
||||
{ key: "download", label: "下载" },
|
||||
{ key: "assets", label: "素材" },
|
||||
{ key: "analyze", label: "拆解" },
|
||||
{ key: "plan", label: "规划" },
|
||||
{ key: "execute", label: "生成" },
|
||||
{ key: "review", label: "审片" },
|
||||
{ key: "compose", label: "合成" },
|
||||
{ key: "final", label: "成片" },
|
||||
]
|
||||
|
||||
function formatClock(ts: number) {
|
||||
if (!ts) return "--:--:--"
|
||||
return new Date(ts * 1000).toLocaleTimeString("zh-CN", { hour12: false })
|
||||
}
|
||||
|
||||
function runVideoUrl(run: AgentRun | null) {
|
||||
if (!run?.final_video_url) return ""
|
||||
return `${API_BASE}${run.final_video_url}`
|
||||
}
|
||||
|
||||
function runContactUrl(run: AgentRun | null) {
|
||||
if (!run?.contact_sheet_url) return ""
|
||||
return `${API_BASE}${run.contact_sheet_url}`
|
||||
}
|
||||
|
||||
export default function AgentPage() {
|
||||
const [url, setUrl] = useState("")
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [run, setRun] = useState<AgentRun | null>(null)
|
||||
const [recent, setRecent] = useState<AgentRun[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const terminalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
|
||||
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((items: AgentRun[]) => {
|
||||
setRecent(items)
|
||||
const latest = items.find((item) => item.status === "executing" || item.status === "reviewing" || item.status === "completed")
|
||||
if (latest) setRun(latest)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!run || run.status === "completed" || run.status === "failed") return
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/agent-runs/${run.id}`, { cache: "no-store" })
|
||||
if (!res.ok) return
|
||||
const next = await res.json()
|
||||
setRun(next)
|
||||
} catch {
|
||||
/* keep current state */
|
||||
}
|
||||
}, 2000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [run?.id, run?.status])
|
||||
|
||||
useEffect(() => {
|
||||
const el = terminalRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [run?.logs.length])
|
||||
|
||||
async function submit() {
|
||||
setError("")
|
||||
if (!url.trim()) {
|
||||
setError("需要 TikTok 链接")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append("tk_url", url.trim())
|
||||
files.slice(0, 6).forEach((file) => form.append("product_files", file))
|
||||
const res = await fetch(`${API_BASE}/agent-runs`, { method: "POST", body: form })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "")
|
||||
throw new Error(text.slice(0, 260) || `HTTP ${res.status}`)
|
||||
}
|
||||
const created = await res.json()
|
||||
setRun(created)
|
||||
setRecent((prev) => [created, ...prev.filter((item) => item.id !== created.id)].slice(0, 8))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeStageIndex = run ? Math.max(0, STAGES.findIndex((item) => item.key === run.stage)) : -1
|
||||
const canStart = !!url.trim() && !submitting
|
||||
const videoSrc = runVideoUrl(run)
|
||||
const contactSrc = runContactUrl(run)
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f3f4f7] text-[#111318]">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[1720px] flex-col gap-5 px-5 py-5">
|
||||
<header className="flex items-center justify-between rounded-[28px] border border-black/5 bg-white/80 px-5 py-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||
<div>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-[0.18em] text-[#7b8190]">SKG Agent Cut</div>
|
||||
<h1 className="mt-1 text-[26px] font-semibold tracking-normal text-[#111318]">一分钟二创出片终端</h1>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 rounded-full bg-[#111318] px-3 py-2 text-[12px] font-medium text-white md:flex">
|
||||
<TerminalSquare className="h-4 w-4 text-[#81d4ff]" />
|
||||
{run ? `${run.status} · ${run.progress}%` : "standby"}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid min-h-[calc(100vh-128px)] grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(520px,1fr)_420px]">
|
||||
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
|
||||
<label className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
|
||||
<Link2 className="h-4 w-4 text-[#0a84ff]" />
|
||||
TikTok 链接
|
||||
</label>
|
||||
<textarea
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://www.tiktok.com/@..."
|
||||
className="h-28 w-full resize-none rounded-[18px] border border-[#d9dee8] bg-white px-4 py-3 text-[14px] leading-relaxed text-[#111318] outline-none transition focus:border-[#0a84ff] focus:ring-4 focus:ring-[#0a84ff]/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
|
||||
<label className="mb-3 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
|
||||
<ImagePlus className="h-4 w-4 text-[#34c759]" />
|
||||
产品图
|
||||
</label>
|
||||
<label className="flex h-32 cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7ceda] bg-white text-center transition hover:border-[#0a84ff] hover:bg-[#f7fbff]">
|
||||
<Upload className="mb-2 h-6 w-6 text-[#7b8190]" />
|
||||
<span className="text-[13px] font-medium text-[#2b3038]">上传产品图</span>
|
||||
<span className="mt-1 text-[12px] text-[#7b8190]">最多 6 张</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const next = Array.from(e.target.files ?? []).slice(0, 6)
|
||||
setFiles(next)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{previews.length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{previews.map((item) => (
|
||||
<div key={`${item.file.name}-${item.file.size}`} className="aspect-square overflow-hidden rounded-[14px] border border-black/5 bg-white">
|
||||
<img src={item.url} alt={item.file.name} className="h-full w-full object-contain" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-[18px] border border-[#ff453a]/20 bg-[#ff453a]/10 px-4 py-3 text-[13px] text-[#9f1d17]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canStart}
|
||||
onClick={submit}
|
||||
className="flex h-14 items-center justify-center gap-2 rounded-[20px] bg-[#111318] text-[15px] font-semibold text-white shadow-[0_16px_40px_rgba(17,19,24,0.18)] transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#b8bec8]"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <Play className="h-5 w-5" />}
|
||||
开始出片
|
||||
</button>
|
||||
|
||||
<div className="mt-auto rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-3">
|
||||
<div className="mb-2 text-[12px] font-semibold text-[#7b8190]">最近任务</div>
|
||||
<div className="space-y-2">
|
||||
{recent.slice(0, 4).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setRun(item)}
|
||||
className="flex w-full items-center justify-between rounded-[16px] bg-white px-3 py-2 text-left text-[12px] text-[#2b3038] transition hover:bg-[#f1f5fb]"
|
||||
>
|
||||
<span className="font-medium">{item.id}</span>
|
||||
<span className="text-[#7b8190]">{item.status}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-[680px] flex-col rounded-[30px] border border-black/5 bg-[#111318] p-4 shadow-[0_24px_80px_rgba(20,25,38,0.16)]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[16px] bg-white/8">
|
||||
<TerminalSquare className="h-5 w-5 text-[#81d4ff]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[16px] font-semibold text-white">Agent Terminal</h2>
|
||||
<p className="text-[12px] text-white/45">{run ? `run ${run.id} · job ${run.job_id}` : "waiting for input"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{run?.status === "failed" ? (
|
||||
<CircleAlert className="h-5 w-5 text-[#ff453a]" />
|
||||
) : run?.status === "completed" ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-[#34c759]" />
|
||||
) : (
|
||||
<Loader2 className={`h-5 w-5 text-[#81d4ff] ${run ? "animate-spin" : ""}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-4 gap-2 lg:grid-cols-8">
|
||||
{STAGES.map((stage, index) => {
|
||||
const active = index <= activeStageIndex || run?.status === "completed"
|
||||
return (
|
||||
<div key={stage.key} className={`rounded-[14px] px-3 py-2 text-[12px] ${active ? "bg-white text-[#111318]" : "bg-white/6 text-white/40"}`}>
|
||||
{stage.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 h-2 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-full rounded-full bg-[#34c759] transition-all duration-700" style={{ width: `${run?.progress ?? 0}%` }} />
|
||||
</div>
|
||||
|
||||
<div ref={terminalRef} className="min-h-0 flex-1 overflow-auto rounded-[22px] border border-white/8 bg-black px-4 py-4 font-mono text-[12px] leading-relaxed text-[#d8f3dc]">
|
||||
{!run && <div className="text-white/35">$ idle</div>}
|
||||
{run?.logs.map((log, index) => (
|
||||
<div key={`${log.ts}-${index}`} className={log.level === "error" ? "text-[#ff8a80]" : log.level === "warn" ? "text-[#ffd166]" : "text-[#d8f3dc]"}>
|
||||
<span className="text-white/30">[{formatClock(log.ts)}]</span> {log.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-[12px] font-semibold uppercase tracking-[0.16em] text-[#7b8190]">Final</div>
|
||||
<h2 className="mt-1 text-[18px] font-semibold text-[#111318]">成片播放器</h2>
|
||||
</div>
|
||||
<Film className="h-5 w-5 text-[#ff9f0a]" />
|
||||
</div>
|
||||
|
||||
<div className="aspect-[9/16] overflow-hidden rounded-[26px] border border-black/8 bg-[#111318]">
|
||||
{videoSrc ? (
|
||||
<video key={videoSrc} src={videoSrc} controls playsInline className="h-full w-full bg-black object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#7b8190]">
|
||||
<Film className="h-8 w-8" />
|
||||
<span className="text-[13px]">等待成片</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contactSrc && (
|
||||
<div className="overflow-hidden rounded-[18px] border border-black/8 bg-white">
|
||||
<img src={contactSrc} alt="final contact sheet" className="w-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<a
|
||||
href={videoSrc || undefined}
|
||||
download
|
||||
className={`flex h-11 items-center justify-center gap-2 rounded-[16px] text-[13px] font-semibold ${videoSrc ? "bg-[#0a84ff] text-white" : "pointer-events-none bg-[#dfe3ea] text-[#8d94a1]"}`}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4" />
|
||||
下载
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRun(null)
|
||||
setError("")
|
||||
}}
|
||||
className="flex h-11 items-center justify-center gap-2 rounded-[16px] bg-[#eef1f6] text-[13px] font-semibold text-[#2b3038] transition hover:bg-[#e3e7ef]"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重来
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -469,29 +469,42 @@ nextjs-portal {
|
||||
信息流工作台 · 登录页同源质感
|
||||
============================================================ */
|
||||
.skg-board-theme {
|
||||
--skg-gold-1: #d6b36a;
|
||||
--skg-gold-2: #c89b3c;
|
||||
--skg-cream: #f5efe3;
|
||||
--skg-bg-1: #0a0a0a;
|
||||
--skg-bg-2: #111111;
|
||||
--skg-bg-3: rgba(255, 255, 255, 0.035);
|
||||
--skg-border: rgba(255, 255, 255, 0.1);
|
||||
--skg-gold-1: #c8cd19;
|
||||
--skg-gold-2: #a2c638;
|
||||
--skg-cream: #f6f6ee;
|
||||
--skg-bg-1: #1b1b1b;
|
||||
--skg-bg-2: #242424;
|
||||
--skg-bg-3: rgba(255, 255, 255, 0.1);
|
||||
--skg-border: rgba(255, 255, 255, 0.14);
|
||||
--skg-text-1: #ffffff;
|
||||
--skg-text-2: rgba(255, 255, 255, 0.62);
|
||||
--skg-text-3: rgba(255, 255, 255, 0.34);
|
||||
--skg-success: #34d399;
|
||||
--skg-warn: #fcd34d;
|
||||
--skg-text-2: rgba(255, 255, 255, 0.56);
|
||||
--skg-text-3: rgba(255, 255, 255, 0.36);
|
||||
--skg-success: #a2c638;
|
||||
--skg-warn: #c8cd19;
|
||||
--skg-danger: #fb7185;
|
||||
--skg-info: #67e8f9;
|
||||
--skg-info: #a6d533;
|
||||
--skg-radius-sm: 6px;
|
||||
--skg-radius-md: 8px;
|
||||
--skg-radius-lg: 12px;
|
||||
--skg-shadow-button: 0 6px 24px -8px rgba(0, 0, 0, 0.45);
|
||||
--skg-radius-lg: 20px;
|
||||
--skg-shadow-button: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||
--skg-shadow-card: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||
--skg-glass-bg: rgba(255, 255, 255, 0.1);
|
||||
--skg-glass-bg-soft: rgba(255, 255, 255, 0.055);
|
||||
--skg-rail: #383838;
|
||||
--skg-wave-bg: rgba(0, 0, 0, 0.35);
|
||||
--skg-wave-fill: rgba(209, 213, 219, 0.74);
|
||||
--skg-wave-stroke-1: rgba(229, 231, 235, 0.7);
|
||||
--skg-wave-stroke-2: rgba(229, 231, 235, 0.52);
|
||||
--skg-wave-grid: rgba(255, 255, 255, 0.14);
|
||||
--skg-wave-marker: rgba(255, 255, 255, 0.12);
|
||||
--skg-wave-hover: rgba(207, 250, 254, 0.7);
|
||||
--skg-wave-playhead: #a7f3d0;
|
||||
--skg-wave-playhead-shadow: rgba(110, 231, 183, 0.85);
|
||||
color: var(--skg-text-1);
|
||||
background:
|
||||
radial-gradient(circle at 52% 4%, rgba(214, 179, 106, 0.1), transparent 30%),
|
||||
radial-gradient(circle at 12% 96%, rgba(214, 179, 106, 0.065), transparent 34%),
|
||||
linear-gradient(120deg, #0a0a0a 0%, #10100f 46%, #050505 100%);
|
||||
radial-gradient(circle at 20% 18%, rgba(162, 198, 56, 0.09), transparent 28%),
|
||||
radial-gradient(circle at 86% 78%, rgba(200, 205, 25, 0.1), transparent 28%),
|
||||
linear-gradient(120deg, #171717 0%, #202020 48%, #101010 100%);
|
||||
}
|
||||
|
||||
.skg-board-theme::before {
|
||||
@@ -501,10 +514,10 @@ nextjs-portal {
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.026) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
opacity: 0.44;
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.016) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.skg-board-theme::after {
|
||||
@@ -514,39 +527,38 @@ nextjs-portal {
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.22), transparent 42%, rgba(0, 0, 0, 0.4)),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.28), transparent 38%, rgba(0, 0, 0, 0.24));
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.2), transparent 45%, rgba(0, 0, 0, 0.42)),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.38), transparent 36%, rgba(0, 0, 0, 0.22));
|
||||
}
|
||||
|
||||
.skg-board-ambient {
|
||||
background:
|
||||
radial-gradient(circle at 78% 0%, rgba(232, 201, 122, 0.08), transparent 30%),
|
||||
radial-gradient(circle at 8% 100%, rgba(214, 179, 106, 0.06), transparent 34%);
|
||||
radial-gradient(circle at 72% 12%, rgba(162, 198, 56, 0.13), transparent 28%),
|
||||
radial-gradient(circle at 18% 92%, rgba(200, 205, 25, 0.12), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-board-topbar,
|
||||
.skg-board-panel {
|
||||
border-color: var(--skg-border) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.022)),
|
||||
rgba(17, 17, 17, 0.74) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
0 18px 54px rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(10px);
|
||||
radial-gradient(circle at 88% 22%, rgba(162, 198, 56, 0.06), transparent 38%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.032)),
|
||||
rgba(30, 30, 30, 0.78) !important;
|
||||
box-shadow: var(--skg-shadow-card);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.skg-board-topbar {
|
||||
background:
|
||||
linear-gradient(100deg, rgba(214, 179, 106, 0.075), rgba(255, 255, 255, 0.03) 54%, rgba(214, 179, 106, 0.035)),
|
||||
rgba(12, 12, 12, 0.76) !important;
|
||||
linear-gradient(100deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.038) 58%, rgba(162, 198, 56, 0.07)),
|
||||
rgba(28, 28, 28, 0.84) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme input:focus,
|
||||
.skg-board-theme textarea:focus,
|
||||
.skg-board-theme select:focus {
|
||||
border-color: rgba(214, 179, 106, 0.58) !important;
|
||||
box-shadow: 0 0 0 2px rgba(214, 179, 106, 0.14);
|
||||
box-shadow: 0 0 0 2px rgba(162, 198, 56, 0.18);
|
||||
}
|
||||
|
||||
.skg-board-theme input[type="checkbox"] {
|
||||
@@ -558,23 +570,182 @@ nextjs-portal {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skg-board-shell {
|
||||
min-height: calc(100vh - 32px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.026), rgba(255, 255, 255, 0.01)),
|
||||
rgba(18, 18, 18, 0.72);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.skg-board-rail {
|
||||
width: 65px;
|
||||
height: calc(100vh - 64px);
|
||||
min-height: 600px;
|
||||
max-height: calc(100vh - 32px);
|
||||
align-self: flex-start;
|
||||
top: 16px;
|
||||
z-index: 30;
|
||||
overflow: visible;
|
||||
transition: width 220ms ease;
|
||||
}
|
||||
|
||||
.skg-board-rail.is-open {
|
||||
width: 397px;
|
||||
}
|
||||
|
||||
.skg-board-rail__strip {
|
||||
width: 65px;
|
||||
height: 100%;
|
||||
min-height: 600px;
|
||||
border: 1px solid #383838;
|
||||
border-radius: 0 70px 70px 0;
|
||||
background:
|
||||
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.1), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
|
||||
#383838;
|
||||
box-shadow: 10px 10px 10px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
transition: width 220ms ease, border-radius 220ms ease;
|
||||
}
|
||||
|
||||
.skg-board-rail.is-open .skg-board-rail__strip {
|
||||
width: 397px;
|
||||
border-radius: 0 34px 34px 0;
|
||||
}
|
||||
|
||||
.skg-board-rail__iconbar {
|
||||
width: 65px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.skg-board-rail__logo {
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
background:
|
||||
radial-gradient(circle at 68% 38%, #a2c638 0 34%, transparent 36%),
|
||||
radial-gradient(circle at 50% 50%, #c8cd19 0 47%, transparent 49%),
|
||||
#ffffff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skg-board-rail__button {
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
transition: color 180ms ease, background 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.skg-board-rail__button:hover,
|
||||
.skg-board-rail__button:focus-visible,
|
||||
.skg-board-rail__button.is-active {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.skg-board-rail__drawer {
|
||||
height: 100%;
|
||||
animation: skgRailDrawerIn 220ms ease both;
|
||||
}
|
||||
|
||||
.skg-board-rail__drawer .skg-board-panel {
|
||||
height: 100%;
|
||||
min-height: 0 !important;
|
||||
border-radius: 0 30px 30px 0;
|
||||
}
|
||||
|
||||
@keyframes skgRailDrawerIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.skg-glass-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(circle at 80% 86%, rgba(162, 198, 56, 0.13), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.034)),
|
||||
rgba(38, 38, 38, 0.76);
|
||||
box-shadow: var(--skg-shadow-card);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.skg-glass-card--flat {
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.022)),
|
||||
rgba(18, 18, 18, 0.62);
|
||||
}
|
||||
|
||||
.skg-audio-waveform {
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
background: var(--skg-wave-bg) !important;
|
||||
}
|
||||
|
||||
.skg-audio-waveform__center {
|
||||
background: var(--skg-wave-grid);
|
||||
}
|
||||
|
||||
.skg-audio-waveform__segment {
|
||||
background: var(--skg-wave-marker);
|
||||
}
|
||||
|
||||
.skg-audio-waveform__hover {
|
||||
background: var(--skg-wave-hover);
|
||||
}
|
||||
|
||||
.skg-audio-waveform__playhead {
|
||||
background: var(--skg-wave-playhead);
|
||||
box-shadow: 0 0 16px var(--skg-wave-playhead-shadow);
|
||||
}
|
||||
|
||||
.skg-status-orb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 78% 32%, #a2c638 0 12%, transparent 13%),
|
||||
conic-gradient(from 40deg, #a2c638 0 74%, rgba(255, 255, 255, 0.22) 75% 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skg-board-theme--light {
|
||||
--skg-bg-1: #faf8f4;
|
||||
--skg-bg-2: #ffffff;
|
||||
--skg-bg-1: #f4f1e8;
|
||||
--skg-bg-2: #fbfaf5;
|
||||
--skg-bg-3: rgba(0, 0, 0, 0.03);
|
||||
--skg-border: rgba(0, 0, 0, 0.08);
|
||||
--skg-text-1: #0a0a0a;
|
||||
--skg-text-2: rgba(0, 0, 0, 0.62);
|
||||
--skg-text-3: rgba(0, 0, 0, 0.34);
|
||||
--skg-border: rgba(72, 78, 56, 0.14);
|
||||
--skg-text-1: #20241b;
|
||||
--skg-text-2: rgba(32, 36, 27, 0.68);
|
||||
--skg-text-3: rgba(32, 36, 27, 0.42);
|
||||
--skg-success: #059669;
|
||||
--skg-warn: #b7791f;
|
||||
--skg-danger: #e11d48;
|
||||
--skg-info: #0891b2;
|
||||
--skg-wave-bg: rgba(255, 255, 255, 0.74);
|
||||
--skg-wave-fill: rgba(80, 90, 70, 0.42);
|
||||
--skg-wave-stroke-1: rgba(47, 57, 44, 0.46);
|
||||
--skg-wave-stroke-2: rgba(47, 57, 44, 0.3);
|
||||
--skg-wave-grid: rgba(72, 78, 56, 0.16);
|
||||
--skg-wave-marker: rgba(72, 78, 56, 0.14);
|
||||
--skg-wave-hover: rgba(23, 96, 111, 0.52);
|
||||
--skg-wave-playhead: #10b981;
|
||||
--skg-wave-playhead-shadow: rgba(16, 185, 129, 0.36);
|
||||
color: var(--skg-text-1);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.18), transparent 31%),
|
||||
radial-gradient(circle at 4% 100%, rgba(214, 179, 106, 0.12), transparent 28%),
|
||||
linear-gradient(126deg, #faf8f4 0%, #f4efe5 48%, #ffffff 100%);
|
||||
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.16), transparent 31%),
|
||||
radial-gradient(circle at 4% 100%, rgba(143, 176, 113, 0.1), transparent 28%),
|
||||
linear-gradient(126deg, #f5f2e9 0%, #ece7dc 48%, #fbfaf5 100%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light::before {
|
||||
@@ -601,17 +772,50 @@ nextjs-portal {
|
||||
.skg-board-theme--light .skg-board-panel {
|
||||
border-color: rgba(82, 93, 62, 0.16) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.48)),
|
||||
rgba(249, 246, 236, 0.7) !important;
|
||||
radial-gradient(circle at 88% 18%, rgba(143, 176, 113, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(249, 247, 239, 0.64)),
|
||||
rgba(246, 243, 234, 0.84) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.78),
|
||||
0 18px 48px rgba(65, 55, 30, 0.1);
|
||||
0 18px 48px rgba(65, 55, 30, 0.12);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-topbar {
|
||||
background:
|
||||
linear-gradient(100deg, rgba(214, 179, 106, 0.14), rgba(143, 176, 113, 0.08) 42%, rgba(255, 255, 255, 0.58)),
|
||||
rgba(252, 249, 241, 0.82) !important;
|
||||
linear-gradient(100deg, rgba(214, 179, 106, 0.1), rgba(143, 176, 113, 0.07) 42%, rgba(255, 255, 255, 0.72)),
|
||||
rgba(250, 248, 241, 0.9) !important;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-shell {
|
||||
border-color: rgba(72, 78, 56, 0.14);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 243, 235, 0.62)),
|
||||
rgba(246, 243, 235, 0.74);
|
||||
box-shadow: 0 24px 70px rgba(74, 68, 44, 0.13);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-rail__strip {
|
||||
border-color: #3f4239;
|
||||
background:
|
||||
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
|
||||
#3f4239;
|
||||
box-shadow: 10px 10px 26px rgba(74, 68, 44, 0.18);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-glass-card {
|
||||
border-color: rgba(72, 78, 56, 0.16);
|
||||
background:
|
||||
radial-gradient(circle at 80% 86%, rgba(143, 176, 113, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(246, 243, 235, 0.64)),
|
||||
rgba(250, 248, 241, 0.82);
|
||||
box-shadow: 0 16px 42px rgba(74, 68, 44, 0.12);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-glass-card--flat {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 243, 235, 0.5)),
|
||||
rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-board-theme-toggle {
|
||||
@@ -748,43 +952,82 @@ nextjs-portal {
|
||||
}
|
||||
|
||||
.skg-stat-card {
|
||||
border: 1px solid rgba(214, 179, 106, 0.18);
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: var(--skg-cream);
|
||||
color: #0a0a0a;
|
||||
box-shadow: var(--skg-shadow-button);
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 92% 86%, rgba(162, 198, 56, 0.22), transparent 48%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background:
|
||||
var(--skg-stat-glow),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.045)),
|
||||
rgba(0, 0, 0, 0.16);
|
||||
color: #ffffff;
|
||||
box-shadow: 8px 8px 10px rgba(0, 0, 0, 0.22);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.skg-stat-card--violet {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 82%, rgba(126, 87, 194, 0.74), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card--lime {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.72), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card--gold {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.7), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card--teal {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.7), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card--green {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.72), transparent 48%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
|
||||
}
|
||||
|
||||
.skg-stat-card__label {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
|
||||
.skg-stat-card__value {
|
||||
color: #0a0a0a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skg-primary-action {
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: #f5efe3;
|
||||
color: #0a0a0a;
|
||||
background: linear-gradient(135deg, #c8cd19, #a2c638);
|
||||
color: #101010;
|
||||
box-shadow: var(--skg-shadow-button);
|
||||
}
|
||||
|
||||
.skg-primary-action:hover {
|
||||
background: #fff7df;
|
||||
background: linear-gradient(135deg, #d6db25, #b0d83d);
|
||||
}
|
||||
|
||||
.skg-secondary-action {
|
||||
border: 1px solid rgba(214, 179, 106, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: var(--skg-radius-md);
|
||||
background: rgba(214, 179, 106, 0.08);
|
||||
color: var(--skg-gold-1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.skg-secondary-action:hover {
|
||||
border-color: rgba(214, 179, 106, 0.54);
|
||||
background: rgba(214, 179, 106, 0.12);
|
||||
color: #f5d98e;
|
||||
border-color: rgba(162, 198, 56, 0.44);
|
||||
background: rgba(162, 198, 56, 0.11);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skg-empty-state {
|
||||
@@ -826,27 +1069,75 @@ nextjs-portal {
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card {
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -12px rgba(133, 96, 21, 0.38);
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(143, 176, 113, 0.2), transparent 50%);
|
||||
border-color: rgba(72, 78, 56, 0.14);
|
||||
background:
|
||||
var(--skg-stat-glow),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(246, 243, 235, 0.58)),
|
||||
rgba(255, 255, 255, 0.56);
|
||||
color: #20241b;
|
||||
box-shadow: 0 12px 28px rgba(74, 68, 44, 0.1);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--violet {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(126, 87, 194, 0.22), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--lime {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.28), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--gold {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.26), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--teal {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.24), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card--green {
|
||||
--skg-stat-glow:
|
||||
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.24), transparent 50%),
|
||||
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card__label {
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
color: rgba(32, 36, 27, 0.48);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-stat-card__value {
|
||||
color: #fff;
|
||||
color: #20241b;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-primary-action {
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px -12px rgba(133, 96, 21, 0.42);
|
||||
background: linear-gradient(135deg, #c8cd19, #a2c638);
|
||||
color: #10140d;
|
||||
box-shadow: 0 12px 28px rgba(128, 144, 37, 0.2);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-primary-action:hover {
|
||||
background: #252525;
|
||||
background: linear-gradient(135deg, #d6db25, #b0d83d);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-secondary-action {
|
||||
border-color: rgba(72, 78, 56, 0.16);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(32, 36, 27, 0.72);
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-secondary-action:hover {
|
||||
border-color: rgba(143, 176, 113, 0.38);
|
||||
background: rgba(143, 176, 113, 0.12);
|
||||
color: #20241b;
|
||||
}
|
||||
|
||||
.skg-board-theme--light .skg-empty-state {
|
||||
|
||||
@@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SKG TK 二创工作台",
|
||||
description: "SKG AI 素材生产管线 · 节点工作流",
|
||||
title: "SKG 营销内容工作台",
|
||||
description: "SKG AI 图片、视频和文案创作台",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FormEvent } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -14,8 +15,14 @@ import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/l
|
||||
import { OasisCanvas } from "@/components/login/oasis-canvas"
|
||||
|
||||
type LoginStatus = "idle" | "loading" | "success"
|
||||
type AuthConfig = {
|
||||
auth_configured?: boolean
|
||||
password_enabled?: boolean
|
||||
feishu_enabled?: boolean
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [remember, setRemember] = useState(true)
|
||||
@@ -25,6 +32,21 @@ export default function LoginPage() {
|
||||
const [status, setStatus] = useState<LoginStatus>("idle")
|
||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch("/api/auth/config", { cache: "no-store", credentials: "include" })
|
||||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data) => {
|
||||
if (!cancelled && data) setAuthConfig(data)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAuthConfig(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const centerX = window.innerWidth / 2
|
||||
@@ -38,6 +60,8 @@ export default function LoginPage() {
|
||||
}, [])
|
||||
|
||||
const disabled = status === "loading" || status === "success"
|
||||
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
|
||||
const passwordEnabled = authConfig?.password_enabled ?? true
|
||||
|
||||
const mood: LoginCharacterMood = useMemo(() => {
|
||||
if (status === "success") return "success"
|
||||
@@ -50,6 +74,7 @@ export default function LoginPage() {
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setHasError(false)
|
||||
if (!passwordEnabled) return
|
||||
if (!username.trim() || !password) {
|
||||
setHasError(true)
|
||||
return
|
||||
@@ -75,6 +100,11 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function onFeishuLogin() {
|
||||
setStatus("loading")
|
||||
window.location.href = "/api/auth/feishu/start?next=/"
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
|
||||
<OasisCanvas />
|
||||
@@ -89,7 +119,29 @@ export default function LoginPage() {
|
||||
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
|
||||
</div>
|
||||
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
|
||||
<div className="space-y-3">
|
||||
{feishuEnabled ? (
|
||||
<button
|
||||
className="mb-3 flex h-11 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-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onFeishuLogin}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
飞书免登录
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{feishuEnabled && passwordEnabled ? (
|
||||
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
|
||||
<span className="h-px flex-1 bg-white/10" />
|
||||
<span>备用账号</span>
|
||||
<span className="h-px flex-1 bg-white/10" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{passwordEnabled ? (
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
|
||||
<UserRound className="h-4 w-4 text-white/45" />
|
||||
@@ -135,9 +187,11 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
|
||||
{passwordEnabled ? (
|
||||
<label className="mt-3 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-[#c89b3c]"
|
||||
type="checkbox"
|
||||
@@ -146,7 +200,8 @@ export default function LoginPage() {
|
||||
onChange={(event) => setRemember(event.target.checked)}
|
||||
/>
|
||||
<span>保持登录</span>
|
||||
</label>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{status === "success" ? (
|
||||
<div className="mt-3">
|
||||
@@ -156,13 +211,15 @@ export default function LoginPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
{passwordEnabled ? (
|
||||
<button
|
||||
className="mt-4 flex h-11 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-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
|
||||
type="submit"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
1736
web/app/page.tsx
1736
web/app/page.tsx
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -319,7 +319,7 @@ export function AudioStrip({ job, open, onClose }: { job: Job | null; open: bool
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-white/12 text-[12px] text-white/45">
|
||||
点击音频卡片后开始解析;完成后这里会按时间显示英文、中文翻译和对应波形。
|
||||
点击音频卡片后开始解析;完成后这里会按时间显示原语言文案、中文镜像和对应波形。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@ type MediaAssetTileProps = {
|
||||
deleting?: boolean
|
||||
deleteDisabled?: boolean
|
||||
actions?: MediaAssetAction[]
|
||||
actionsAlwaysVisible?: boolean
|
||||
disablePreview?: boolean
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ export function MediaAssetTile({
|
||||
deleting = false,
|
||||
deleteDisabled = false,
|
||||
actions = [],
|
||||
actionsAlwaysVisible = false,
|
||||
disablePreview = false,
|
||||
}: MediaAssetTileProps) {
|
||||
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
|
||||
@@ -200,7 +202,7 @@ export function MediaAssetTile({
|
||||
{topRight ? <div className="pointer-events-none absolute right-1 top-1 z-10">{topRight}</div> : null}
|
||||
{bottom ? <div className="pointer-events-none absolute bottom-1 left-1 right-1 z-10">{bottom}</div> : null}
|
||||
{(actions.length || onDelete) ? (
|
||||
<div className="absolute right-1 top-1 z-20 flex flex-col gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<div className={`absolute right-1 top-1 z-20 flex flex-col gap-0.5 transition ${actionsAlwaysVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"}`}>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
|
||||
@@ -2017,7 +2017,7 @@ export function ASRNode({ data, selected }: any) {
|
||||
onTogglePin={() => d.onToggleNodePin?.("asr")}
|
||||
>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)]">
|
||||
OpenAI-compatible ASR · 英文带时间戳分段
|
||||
OpenAI-compatible ASR · 原语言带时间戳分段
|
||||
</div>
|
||||
{d.job && d.job.transcript.length > 0 && (
|
||||
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
|
||||
|
||||
@@ -316,6 +316,54 @@ export async function getRuntimeHealth(): Promise<RuntimeHealth> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface CreativeCopyVariant {
|
||||
title: string
|
||||
hook_zh: string
|
||||
script_zh: string
|
||||
script_en: string
|
||||
image_prompt_en: string
|
||||
video_prompt_en: string
|
||||
caption_zh: string
|
||||
hashtags: string[]
|
||||
}
|
||||
|
||||
export interface CreativeCopyResult {
|
||||
model: string
|
||||
variants: CreativeCopyVariant[]
|
||||
}
|
||||
|
||||
export async function generateCreativeCopy(body: {
|
||||
goal: string
|
||||
product?: string
|
||||
audience?: string
|
||||
platform?: string
|
||||
tone?: string
|
||||
seconds?: number
|
||||
source_text?: string
|
||||
}): Promise<CreativeCopyResult> {
|
||||
const res = await fetch(`${API_BASE}/creative/copy`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw apiError("generateCreativeCopy", res.status, txt)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createCreativeImageJob(file?: File | null): Promise<Job> {
|
||||
const fd = new FormData()
|
||||
if (file) fd.append("file", file)
|
||||
const res = await fetch(`${API_BASE}/creative/jobs/image`, { method: "POST", body: fd })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw apiError("createCreativeImageJob", res.status, txt)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// 把 ImageRef 解析成可显示的 src URL
|
||||
export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
|
||||
if (ref.kind === "keyframe") {
|
||||
@@ -953,6 +1001,11 @@ export interface ProductRefStateItem {
|
||||
export interface Job {
|
||||
id: string
|
||||
url: string
|
||||
owner_id?: string
|
||||
owner_name?: string
|
||||
owner_email?: string
|
||||
owner_provider?: string
|
||||
tenant_key?: string
|
||||
status: JobStatus
|
||||
progress: number
|
||||
message?: string
|
||||
@@ -975,6 +1028,11 @@ export interface BackendHealth {
|
||||
ok: boolean
|
||||
llm_configured: boolean
|
||||
auth_configured?: boolean
|
||||
auth_modes?: {
|
||||
password?: boolean
|
||||
feishu?: boolean
|
||||
data_isolation?: boolean
|
||||
}
|
||||
base_url: string
|
||||
models?: {
|
||||
asr?: string
|
||||
@@ -1071,6 +1129,9 @@ export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }
|
||||
export interface JobSummary {
|
||||
id: string
|
||||
url: string
|
||||
owner_name?: string
|
||||
owner_email?: string
|
||||
owner_provider?: string
|
||||
status: JobStatus
|
||||
progress: number
|
||||
message: string
|
||||
|
||||
Reference in New Issue
Block a user