Logankunfall commited on
Commit
337ee27
·
verified ·
1 Parent(s): 064283f

Upload 8 files

Browse files
Files changed (8) hide show
  1. .dockerignore +32 -0
  2. Dockerfile +40 -0
  3. README.md +156 -11
  4. package.json +20 -0
  5. public/index.html +557 -0
  6. public/script.js +1010 -0
  7. public/style.css +1136 -0
  8. server.js +598 -0
.dockerignore ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ignore Node dependencies and caches
2
+ node_modules
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+ pnpm-debug.log*
7
+ .cache
8
+ .tmp
9
+ *.log
10
+
11
+ # Ignore environment files and secrets
12
+ .env
13
+ .env.*
14
+ stored_api_key.json
15
+ stored_api_key*.json
16
+
17
+ # OS artifacts
18
+ .DS_Store
19
+ Thumbs.db
20
+
21
+ # Docker compose (not needed in image)
22
+ docker-compose.yml
23
+
24
+ # Runtime generated media/uploads
25
+ video/
26
+ uploads/
27
+
28
+ # Tests and temporary directories
29
+ tmp/
30
+ temp/
31
+ tests/
32
+ test/
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FAL AI 视频生成器 - Hugging Face Spaces (Docker Runtime)
2
+ # Node 18 Alpine minimal image
3
+ FROM node:18-alpine
4
+
5
+ # Set working directory
6
+ WORKDIR /app
7
+
8
+ # Install runtime tools (curl) for HEALTHCHECK
9
+ RUN apk add --no-cache curl
10
+
11
+ # Set env
12
+ ENV NODE_ENV=production
13
+
14
+ # Copy package manifests
15
+ COPY package.json package-lock.json* ./
16
+
17
+ # Install deps
18
+ RUN npm ci --only=production || npm install --production
19
+
20
+ # Copy app code
21
+ COPY server.js ./server.js
22
+ COPY public ./public
23
+
24
+ # Create writable dirs
25
+ RUN mkdir -p video uploads && \
26
+ adduser -D -h /app appuser && \
27
+ chown -R appuser:appuser /app
28
+
29
+ # Expose default port
30
+ EXPOSE 7860
31
+
32
+ # Healthcheck (uses PORT if provided by platform)
33
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
34
+ CMD curl -sf http://127.0.0.1:${PORT:-7860}/api/health || exit 1
35
+
36
+ # Run as non-root
37
+ USER appuser
38
+
39
+ # Start
40
+ CMD ["node","server.js"]
README.md CHANGED
@@ -1,11 +1,156 @@
1
- ---
2
- title: FALMOVIE
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FAL AI 视频生成器(Hugging Face Spaces 版)
2
+
3
+ 本目录为 Hugging Face Spaces 部署包,包含运行所需的前端与后端文件,开箱即用。后端与前端逻辑与根目录保持一致,仅进行目录组织与便于部署的说明。
4
+
5
+ - 后端文件:[server.js](huggingface-deploy/server.js)
6
+ - 前端文件夹:[public/index.html](huggingface-deploy/public/index.html), [public/script.js](huggingface-deploy/public/script.js), [public/style.css](huggingface-deploy/public/style.css)
7
+ - 包配置:[package.json](huggingface-deploy/package.json)
8
+
9
+ ## 部署到 Hugging Face Spaces(Node.js)
10
+
11
+ 1. Hugging Face 创建 Space,选择 Runtime:Node.js。
12
+ 2. 将本目录内容上传至 Space 根目录(保持文件结构一致)。
13
+ 3. 在 Space Settings → Secrets 添加环境变量:
14
+ - 名称:`FAL_KEY`
15
+ - 值:您的 FAL AI API Key(从 https://fal.ai/dashboard 获取)
16
+ 4. 启动命令默认使用 [package.json](huggingface-deploy/package.json) 的 `start` 脚本:`node server.js`。
17
+ 5. 端口:后端监听 `process.env.PORT || 7860` 且绑定到 `0.0.0.0`,与 Spaces 兼容。
18
+
19
+ 提示:
20
+ - 若不使用环境变量,也可以在前端点击“API 密钥设置”中的“保存设置”按钮,将密钥写入服务目录下 `stored_api_key.json`(类 Space 环境可能不保证持久化,建议优先使用 Secret)。
21
+ - 本包包含完整的前端资源与功能,无需改动即可运行。
22
+
23
+ ## 本地运行
24
+
25
+ 在本地进行快速验证:
26
+
27
+ - 安装依赖(Node.js 18+)
28
+ - 进入部署目录后执行:
29
+ - Windows:`cd huggingface-deploy && npm install && npm start`
30
+ - macOS/Linux:`cd huggingface-deploy && npm install && npm start`
31
+ - 启动成功后访问 `http://localhost:7860`。
32
+
33
+ 注意:
34
+ - 若在本地未配置 `FAL_KEY` 环境变量,请在页面中通过“API 密钥设置”临时设置或保存密钥。
35
+
36
+ ## 前端使用说明(要点)
37
+
38
+ - 打开页面后,点击右上角“API 密钥设置”:
39
+ - 可临时设置或保存密钥到服务端(Space 推荐使用 Secret)。
40
+ - 提供“测试连接”,使用免配额健康检查,验证密钥是否有效。
41
+ - 三个功能页:
42
+ - 图片转视频
43
+ - 文本转视频
44
+ - 视频库(生成的视频本地保存后自动列出)
45
+ - 生成栏(队列):
46
+ - 页面顶部标签下方新增“生成队列”,每个任务独立显示进度与状态,不再使用全屏遮罩。
47
+ - 支持同时生成多个视频;生成过程中不锁定按钮,表单可继续调整图片与提示词再次生成。
48
+ - 移动端优化:
49
+ - 小屏布局优化、队列卡片化进度显示、按钮与控件间距适配。
50
+ - 配色优化:
51
+ - 已移除绿色成功色系,统一采用蓝紫系主色;通知“成功”为蓝色,交互主色为蓝色/紫色。
52
+
53
+ ## 服务端端点(概览)
54
+
55
+ - 保存密钥:POST `/api/save-key`(写入 `stored_api_key.json`)
56
+ - 检查密钥来源:GET `/api/check-key`
57
+ - 测试密钥:POST `/api/test-key`(免配额健康检查)
58
+ - 图片转视频:POST `/api/image-to-video`(表单支持文件或图片URL;模型切换)
59
+ - 文本转视频:POST `/api/text-to-video`(JSON;模型切换)
60
+ - 获取本地视频列表:GET `/api/videos`
61
+ - 静态视频访问:`/video/*`
62
+
63
+ 说明:
64
+ - 服务端会在生成完成后尝试下载远端视频至本地 `video/` 目录,并在“视频库”中展示。
65
+ - 若远端下载失败,仍会返回远端视频 URL,不影响主流程。
66
+
67
+ ## 目录结构(部署包)
68
+
69
+ - [server.js](huggingface-deploy/server.js):Node.js/Express 服务(与根目录版本一致)
70
+ - public/
71
+ - [index.html](huggingface-deploy/public/index.html):页面结构(含生成队列)
72
+ - [script.js](huggingface-deploy/public/script.js):前端交互(并发生成、非全屏进度、视频库刷新)
73
+ - [style.css](huggingface-deploy/public/style.css):样式(移动端优化、去绿色配色)
74
+ - [package.json](huggingface-deploy/package.json):依赖与启动脚本
75
+ - README.md(本说明)
76
+
77
+ ## 注意事项
78
+
79
+ - Spaces 的文件写入可能不持久,建议:
80
+ - 密钥通过 Secret 注入(`FAL_KEY`)。
81
+ - 生成的视频保存在 `video/`(Spaces 持久化行为视运行类型与设置,必要时使用外部存储)。
82
+ - 若你仅通过 Secret 设置密钥,不需要点击“保存设置”;前端会识别后端已配置的环境变量状态。
83
+ - 服务默认端口 7860,可在 Space 环境由系统注入 `PORT`,服务会自动适配。
84
+
85
+ ## 更新同步
86
+
87
+ 当你在根目录修改前端或后端:
88
+ - 可将根目录的 [public](public/index.html) 与 [server.js](server.js) 同步复制到本部署目录。
89
+ - 已内置的��端“生成队列”与配色优化已同步至此包,更新后同样可用。
90
+
91
+ ## 使用 Docker 本地运行
92
+
93
+ - 先决条件:
94
+ - 已安装 Docker(推荐 24+)
95
+ - 无需本地 Node.js 环境(镜像基于 Node 18 Alpine)
96
+ - 构建镜像:
97
+ - 在目录 [huggingface-deploy](huggingface-deploy) 下执行
98
+ - Windows(CMD):
99
+ - `docker build -t fal-video-generator:latest .`
100
+ - macOS/Linux:
101
+ - `docker build -t fal-video-generator:latest .`
102
+ - 运行容器(映射端口与持久化视频/上传目录):
103
+ - Windows(CMD):
104
+ - `docker run --rm -p 7860:7860 -e FAL_KEY=你的密钥 -v %cd%\\video:/app/video -v %cd%\\uploads:/app/uploads fal-video-generator:latest`
105
+ - macOS/Linux:
106
+ - `docker run --rm -p 7860:7860 -e FAL_KEY=你的密钥 -v "$(pwd)/video:/app/video" -v "$(pwd)/uploads:/app/uploads" fal-video-generator:latest`
107
+ - 访问地址:
108
+ - http://localhost:7860
109
+ - 说明:
110
+ - 容器内服务监听 `process.env.PORT || 7860` 并绑定 `0.0.0.0`(见 [server.js](huggingface-deploy/server.js))
111
+ - 健康检查:容器每 30 秒检查一次 `/api/health`(见 [Dockerfile](huggingface-deploy/Dockerfile))
112
+ - 若希望容器重启后仍保留视频文件,务必挂载宿主机目录到 `/app/video` 和 `/app/uploads`
113
+
114
+ ---
115
+
116
+ ## 部署到 Hugging Face Spaces(Docker Runtime)
117
+
118
+ - 创建 Space:
119
+ - Runtime 选择 "Docker"
120
+ - 仓库内容(Space 根目录必须包含以下文件/目录):
121
+ - [Dockerfile](huggingface-deploy/Dockerfile)
122
+ - [package.json](huggingface-deploy/package.json)
123
+ - [server.js](huggingface-deploy/server.js)
124
+ - public/
125
+ - [index.html](huggingface-deploy/public/index.html)
126
+ - [script.js](huggingface-deploy/public/script.js)
127
+ - [style.css](huggingface-deploy/public/style.css)
128
+ - 上传方式:
129
+ - 请将 [huggingface-deploy](huggingface-deploy) 目录中的“内容”上传到 Space 仓库的根目录(不要包含上层路径),确保 `Dockerfile` 位于仓库根目录
130
+ - 环境变量/Secrets:
131
+ - 在 Space Settings → Secrets 添加:
132
+ - `FAL_KEY`:你的 FAL AI API Key(https://fal.ai/dashboard 获取)
133
+ - 端口与启动:
134
+ - Spaces 会注入 `PORT` 环境变量;服务端在 [server.js](huggingface-deploy/server.js) 中已自动适配
135
+ - [Dockerfile](huggingface-deploy/Dockerfile) 使用 `CMD ["node","server.js"]`,无需额外启动命令
136
+ - EXPOSE 7860 仅为文档性声明,实际由平台端口映射管理
137
+ - 健康检查与运行状态:
138
+ - [Dockerfile](huggingface-deploy/Dockerfile) 中 `HEALTHCHECK` 会探测 `/api/health`,用于平台判断容器是否健康
139
+ - 文件持久化与存储:
140
+ - 容器内生成的视频保存到 `/app/video` 并在前端“视频库”页展示;Spaces 的持久化取决于运行类型与策略
141
+ - 若需长期持久化,可考虑外部对象存储或将生成结果回传至你自己的存储服务
142
+
143
+ ---
144
+
145
+ ## 常见问题(Docker/Spaces)
146
+
147
+ - API 密钥未配置/401:
148
+ - 请在 Secrets 中设置 `FAL_KEY`,或在页面中通过“API 密钥设置”临时输入/保存(保存到 `stored_api_key.json` 的持久性由运行环境决定)
149
+ - 网络受限/无法访问 FAL API:
150
+ - 若平台限制出网,请在 Space 设置中开启 `Internet Access`
151
+ - 生成失败/速率限制(429):
152
+ - 提示“Rate Limited”,说明请求过于频繁,请降低并发或稍后重试
153
+ - Node 版本兼容:
154
+ - 镜像基于 Node 18(见 [Dockerfile](huggingface-deploy/Dockerfile)),与依赖版本兼容
155
+ - 本地验证与对齐:
156
+ - 你可先按“使用 Docker 本地运行”步骤验证,再将同样的目录内容上传到 Spaces
package.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "huggingface-space-fal-video-generator",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "FAL AI 视频生成器 - Hugging Face Spaces 部署包",
6
+ "main": "server.js",
7
+ "scripts": {
8
+ "start": "node server.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "dependencies": {
14
+ "@fal-ai/client": "^0.10.0",
15
+ "cors": "^2.8.5",
16
+ "dotenv": "^16.4.5",
17
+ "express": "^4.19.2",
18
+ "multer": "^1.4.5-lts.1"
19
+ }
20
+ }
public/index.html ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>FAL AI 视频生成器</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
10
+ </head>
11
+ <body>
12
+ <div class="container">
13
+ <!-- 头部 -->
14
+ <header class="header">
15
+ <div class="header-content">
16
+ <h1 class="title">
17
+ <i class="fas fa-video"></i>
18
+ FAL AI 视频生成器
19
+ </h1>
20
+ <div class="header-controls">
21
+ <button id="apiKeyButton" class="api-key-button" title="API 密钥设置">
22
+ <i class="fas fa-key"></i>
23
+ <span id="apiKeyStatus">未配置</span>
24
+ </button>
25
+ <button id="themeToggle" class="theme-toggle" title="切换主题">
26
+ <i class="fas fa-moon"></i>
27
+ </button>
28
+ </div>
29
+ </div>
30
+ </header>
31
+
32
+ <!-- API Key 设置弹窗 -->
33
+ <div id="apiKeyModal" class="modal" style="display: none;">
34
+ <div class="modal-content">
35
+ <div class="modal-header">
36
+ <h3><i class="fas fa-key"></i> API 密钥设置</h3>
37
+ <button id="closeModal" class="close-button">
38
+ <i class="fas fa-times"></i>
39
+ </button>
40
+ </div>
41
+ <div class="modal-body">
42
+ <div class="api-key-info">
43
+ <p><strong>当前状态:</strong> <span id="modalApiKeyStatus">检查中...</span></p>
44
+ <p class="api-key-hint">
45
+ <i class="fas fa-info-circle"></i>
46
+ 请在 <a href="https://fal.ai/dashboard" target="_blank">FAL AI 控制台</a> 获取您的 API 密钥
47
+ </p>
48
+ <p class="api-key-warning">
49
+ <i class="fas fa-shield-alt"></i>
50
+ <strong>重要提醒:</strong> "测试连接" 使用免费健康检查端点,不会消耗 API 配额或产生费用
51
+ </p>
52
+ </div>
53
+
54
+ <div class="form-group">
55
+ <label for="apiKeyInput" class="form-label">
56
+ <i class="fas fa-key"></i>
57
+ FAL AI API 密钥
58
+ </label>
59
+ <div class="api-key-input-group">
60
+ <input
61
+ type="password"
62
+ id="apiKeyInput"
63
+ class="form-input"
64
+ placeholder="输入您的 FAL AI API 密钥"
65
+ >
66
+ <button id="toggleApiKeyVisibility" class="toggle-visibility" type="button">
67
+ <i class="fas fa-eye"></i>
68
+ </button>
69
+ </div>
70
+ </div>
71
+
72
+ <div class="api-key-options">
73
+ <label class="checkbox-label">
74
+ <input type="checkbox" id="saveApiKey" checked>
75
+ <span class="checkmark"></span>
76
+ 保存 API 密钥到本地(下次自动使用)
77
+ </label>
78
+ </div>
79
+
80
+ <div class="modal-actions">
81
+ <button id="saveApiKeyButton" class="save-button">
82
+ <i class="fas fa-save"></i>
83
+ 保存设置
84
+ </button>
85
+ <button id="testApiKeyButton" class="test-button">
86
+ <i class="fas fa-plug"></i>
87
+ 测试连接
88
+ </button>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- 主要内容 -->
95
+ <main class="main">
96
+ <!-- 功能选择标签 -->
97
+ <div class="tabs">
98
+ <button class="tab-button active" data-tab="image-to-video">
99
+ <i class="fas fa-image"></i>
100
+ 图片转视频
101
+ </button>
102
+ <button class="tab-button" data-tab="text-to-video">
103
+ <i class="fas fa-text-width"></i>
104
+ 文本转视频
105
+ </button>
106
+ <button class="tab-button" data-tab="video-library">
107
+ <i class="fas fa-video"></i>
108
+ 视频库
109
+ </button>
110
+ </div>
111
+
112
+ <!-- 生成队列(生成栏进度) -->
113
+ <div id="generationQueue" class="generation-queue">
114
+ <div class="queue-header">
115
+ <h3><i class="fas fa-tasks"></i> 生成队列</h3>
116
+ <span id="queueCount" class="queue-count">0 任务</span>
117
+ </div>
118
+ <div id="queueList" class="queue-list empty">
119
+ <div class="empty-queue">
120
+ <i class="fas fa-hourglass-half"></i>
121
+ <p>暂无进行中的任务</p>
122
+ <p class="upload-hint">点击上方“生成视频”即可开始一个新任务</p>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- 图片转视频面板 -->
128
+ <div id="image-to-video" class="tab-content active">
129
+ <div class="form-container">
130
+ <form id="imageToVideoForm" class="generation-form">
131
+ <!-- 图片上传区域 -->
132
+ <div class="form-group">
133
+ <label class="form-label">
134
+ <i class="fas fa-image"></i>
135
+ 选择图片
136
+ </label>
137
+ <div class="image-upload-area" id="imageUploadArea">
138
+ <div class="upload-placeholder">
139
+ <i class="fas fa-cloud-upload-alt"></i>
140
+ <p>点击或拖拽上传图片</p>
141
+ <p class="upload-hint">支持 JPG, PNG, GIF 格式,最大 50MB</p>
142
+ </div>
143
+ <input type="file" id="imageFile" accept="image/*" style="display: none;">
144
+ <div class="image-preview" id="imagePreview" style="display: none;">
145
+ <img id="previewImg" src="" alt="预览图片">
146
+ <button type="button" class="remove-image" id="removeImage">
147
+ <i class="fas fa-times"></i>
148
+ </button>
149
+ </div>
150
+ </div>
151
+ <div class="form-group">
152
+ <label class="form-label">或输入图片URL</label>
153
+ <input type="url" id="imageUrl" class="form-input" placeholder="https://example.com/image.jpg">
154
+ </div>
155
+ </div>
156
+
157
+ <!-- 文本提示 -->
158
+ <div class="form-group">
159
+ <label for="i2vPrompt" class="form-label">
160
+ <i class="fas fa-edit"></i>
161
+ 文本描述 *
162
+ </label>
163
+ <textarea
164
+ id="i2vPrompt"
165
+ class="form-textarea"
166
+ placeholder="描述你想要生成的视频内容,例如:白龙战士静静站立,眼中充满决心和力量。摄像机慢慢靠近或围绕战士转动,突出角色的强大存在感和英雄气质。"
167
+ required
168
+ ></textarea>
169
+ </div>
170
+
171
+ <!-- 模型切换(图片转视频) -->
172
+ <div class="form-group">
173
+ <label for="i2vModel" class="form-label">
174
+ <i class="fas fa-brain"></i>
175
+ 模型选择
176
+ </label>
177
+ <select id="i2vModel" class="form-select">
178
+ <option value="seedance-pro-fast" selected>Seedance 1.0 Pro Fast(Bytedance)</option>
179
+ <option value="wan-v2.2-a14b">WAN v2.2-a14b(FAL AI)</option>
180
+ </select>
181
+ <p class="upload-hint">不同模型支持的参数不同,选择后将自动切换对应设置。</p>
182
+ </div>
183
+
184
+ <!-- 高级设置 -->
185
+ <details class="advanced-settings">
186
+ <summary>
187
+ <i class="fas fa-cog"></i>
188
+ 高级设置
189
+ </summary>
190
+ <div class="settings-grid">
191
+ <!-- WAN 模型设置(图片转视频) -->
192
+ <div id="i2vWanSettings" class="model-settings" style="display: none;">
193
+ <div class="form-group">
194
+ <label for="i2vNegativePrompt" class="form-label">负面提示</label>
195
+ <textarea id="i2vNegativePrompt" class="form-textarea" placeholder="描述不希望出现的内容"></textarea>
196
+ </div>
197
+
198
+ <div class="form-group">
199
+ <label for="i2vFrames" class="form-label">帧数 (17-121)</label>
200
+ <input type="range" id="i2vFrames" class="form-range" min="17" max="121" value="81">
201
+ <span class="range-value" id="i2vFramesValue">81</span>
202
+ </div>
203
+
204
+ <div class="form-group">
205
+ <label for="i2vFps" class="form-label">帧率 (4-60)</label>
206
+ <input type="range" id="i2vFps" class="form-range" min="4" max="60" value="16">
207
+ <span class="range-value" id="i2vFpsValue">16</span>
208
+ </div>
209
+
210
+ <div class="form-group">
211
+ <label for="i2vResolution" class="form-label">分辨率</label>
212
+ <select id="i2vResolution" class="form-select">
213
+ <option value="480p">480p</option>
214
+ <option value="580p">580p</option>
215
+ <option value="720p" selected>720p</option>
216
+ </select>
217
+ </div>
218
+
219
+ <div class="form-group">
220
+ <label for="i2vAspectRatio" class="form-label">宽高比</label>
221
+ <select id="i2vAspectRatio" class="form-select">
222
+ <option value="auto" selected>自动</option>
223
+ <option value="16:9">16:9</option>
224
+ <option value="9:16">9:16</option>
225
+ <option value="1:1">1:1</option>
226
+ </select>
227
+ </div>
228
+
229
+ <div class="form-group">
230
+ <label for="i2vQuality" class="form-label">视频质量</label>
231
+ <select id="i2vQuality" class="form-select">
232
+ <option value="low">低</option>
233
+ <option value="medium">中</option>
234
+ <option value="high" selected>高</option>
235
+ <option value="maximum">最高</option>
236
+ </select>
237
+ </div>
238
+
239
+ <div class="form-group">
240
+ <label class="checkbox-label">
241
+ <input type="checkbox" id="i2vDisableSafety">
242
+ <span class="checkmark"></span>
243
+ 关闭内容审查 (生成更自由的内容)
244
+ </label>
245
+ </div>
246
+ </div>
247
+
248
+ <!-- Seedance 1.0 Pro Fast 模型设置(图片转视频) -->
249
+ <div id="i2vSeedanceSettings" class="model-settings">
250
+ <div class="form-group">
251
+ <label for="i2vSeedAspectRatio" class="form-label">宽高比</label>
252
+ <select id="i2vSeedAspectRatio" class="form-select">
253
+ <option value="auto" selected>自动</option>
254
+ <option value="16:9">16:9</option>
255
+ <option value="21:9">21:9</option>
256
+ <option value="4:3">4:3</option>
257
+ <option value="1:1">1:1</option>
258
+ <option value="3:4">3:4</option>
259
+ <option value="9:16">9:16</option>
260
+ </select>
261
+ </div>
262
+
263
+ <div class="form-group">
264
+ <label for="i2vSeedResolution" class="form-label">分辨率</label>
265
+ <select id="i2vSeedResolution" class="form-select">
266
+ <option value="480p">480p</option>
267
+ <option value="720p">720p</option>
268
+ <option value="1080p" selected>1080p</option>
269
+ </select>
270
+ </div>
271
+
272
+ <div class="form-group">
273
+ <label for="i2vSeedDuration" class="form-label">时长(秒)</label>
274
+ <select id="i2vSeedDuration" class="form-select">
275
+ <option value="3">3</option>
276
+ <option value="4">4</option>
277
+ <option value="5" selected>5</option>
278
+ <option value="6">6</option>
279
+ <option value="7">7</option>
280
+ <option value="8">8</option>
281
+ <option value="9">9</option>
282
+ <option value="10">10</option>
283
+ <option value="11">11</option>
284
+ <option value="12">12</option>
285
+ </select>
286
+ </div>
287
+
288
+ <div class="form-group">
289
+ <label class="checkbox-label">
290
+ <input type="checkbox" id="i2vCameraFixed">
291
+ <span class="checkmark"></span>
292
+ 固定相机(camera_fixed)
293
+ </label>
294
+ </div>
295
+
296
+ <div class="form-group">
297
+ <label for="i2vSeedValue" class="form-label">随机种子(-1 为随机)</label>
298
+ <input type="number" id="i2vSeedValue" class="form-input" value="-1" step="1" min="-1" placeholder="-1">
299
+ </div>
300
+
301
+ <div class="form-group">
302
+ <label class="checkbox-label">
303
+ <input type="checkbox" id="i2vEnableSafety" checked>
304
+ <span class="checkmark"></span>
305
+ 启用内容审查(enable_safety_checker)
306
+ </label>
307
+ <p class="upload-hint">提示:Playground 可能不允许关闭安全审查;通过 API 可配置。</p>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ </details>
312
+
313
+ <!-- 提交按钮 -->
314
+ <button type="submit" class="submit-button" id="i2vSubmitButton">
315
+ <i class="fas fa-magic"></i>
316
+ 生成视频
317
+ </button>
318
+ </form>
319
+ </div>
320
+ </div>
321
+
322
+ <!-- 文本转视频面板 -->
323
+ <div id="text-to-video" class="tab-content">
324
+ <div class="form-container">
325
+ <form id="textToVideoForm" class="generation-form">
326
+ <!-- 文本提示 -->
327
+ <div class="form-group">
328
+ <label for="t2vPrompt" class="form-label">
329
+ <i class="fas fa-edit"></i>
330
+ 文本描述 *
331
+ </label>
332
+ <textarea
333
+ id="t2vPrompt"
334
+ class="form-textarea"
335
+ placeholder="描述你想要生成的视频内容,例如:一只可爱的小猫在花园里玩耍,阳光透过树叶洒下斑驳的光影。"
336
+ required
337
+ ></textarea>
338
+ </div>
339
+
340
+ <!-- 模型切换 -->
341
+ <div class="form-group">
342
+ <label for="t2vModel" class="form-label">
343
+ <i class="fas fa-brain"></i>
344
+ 模型选择
345
+ </label>
346
+ <select id="t2vModel" class="form-select">
347
+ <option value="seedance-pro-fast" selected>Seedance 1.0 Pro Fast(Bytedance)</option>
348
+ <option value="wan-v2.2-a14b">WAN v2.2-a14b(FAL AI)</option>
349
+ </select>
350
+ <p class="upload-hint">不同模型支持的参数不同,选择后将自动切换对应设置。</p>
351
+ </div>
352
+
353
+ <!-- 高级设置 -->
354
+ <details class="advanced-settings">
355
+ <summary>
356
+ <i class="fas fa-cog"></i>
357
+ 高级设置
358
+ </summary>
359
+
360
+ <div class="settings-grid">
361
+ <!-- WAN 模型设置 -->
362
+ <div id="wanSettings" class="model-settings" style="display: none;">
363
+ <div class="form-group">
364
+ <label for="t2vNegativePrompt" class="form-label">负面提示</label>
365
+ <textarea id="t2vNegativePrompt" class="form-textarea" placeholder="描述不希望出现的内容"></textarea>
366
+ </div>
367
+
368
+ <div class="form-group">
369
+ <label for="t2vFrames" class="form-label">帧数 (17-121)</label>
370
+ <input type="range" id="t2vFrames" class="form-range" min="17" max="121" value="81">
371
+ <span class="range-value" id="t2vFramesValue">81</span>
372
+ </div>
373
+
374
+ <div class="form-group">
375
+ <label for="t2vFps" class="form-label">帧率 (4-60)</label>
376
+ <input type="range" id="t2vFps" class="form-range" min="4" max="60" value="16">
377
+ <span class="range-value" id="t2vFpsValue">16</span>
378
+ </div>
379
+
380
+ <div class="form-group">
381
+ <label for="t2vResolution" class="form-label">分辨率</label>
382
+ <select id="t2vResolution" class="form-select">
383
+ <option value="480p">480p</option>
384
+ <option value="580p">580p</option>
385
+ <option value="720p" selected>720p</option>
386
+ </select>
387
+ </div>
388
+
389
+ <div class="form-group">
390
+ <label for="t2vAspectRatio" class="form-label">宽高比</label>
391
+ <select id="t2vAspectRatio" class="form-select">
392
+ <option value="16:9" selected>16:9</option>
393
+ <option value="9:16">9:16</option>
394
+ <option value="1:1">1:1</option>
395
+ </select>
396
+ </div>
397
+
398
+ <div class="form-group">
399
+ <label for="t2vQuality" class="form-label">视频质量</label>
400
+ <select id="t2vQuality" class="form-select">
401
+ <option value="low">低</option>
402
+ <option value="medium">中</option>
403
+ <option value="high" selected>高</option>
404
+ <option value="maximum">最高</option>
405
+ </select>
406
+ </div>
407
+
408
+ <div class="form-group">
409
+ <label class="checkbox-label">
410
+ <input type="checkbox" id="t2vDisableSafety">
411
+ <span class="checkmark"></span>
412
+ 关闭内容审查 (生成更自由的内容)
413
+ </label>
414
+ </div>
415
+ </div>
416
+
417
+ <!-- Seedance 1.0 Pro Fast 模型设置 -->
418
+ <div id="seedanceSettings" class="model-settings">
419
+ <div class="form-group">
420
+ <label for="seedAspectRatio" class="form-label">宽高比</label>
421
+ <select id="seedAspectRatio" class="form-select">
422
+ <option value="16:9" selected>16:9</option>
423
+ <option value="21:9">21:9</option>
424
+ <option value="4:3">4:3</option>
425
+ <option value="1:1">1:1</option>
426
+ <option value="3:4">3:4</option>
427
+ <option value="9:16">9:16</option>
428
+ </select>
429
+ </div>
430
+
431
+ <div class="form-group">
432
+ <label for="seedResolution" class="form-label">分辨率</label>
433
+ <select id="seedResolution" class="form-select">
434
+ <option value="480p">480p</option>
435
+ <option value="720p">720p</option>
436
+ <option value="1080p" selected>1080p</option>
437
+ </select>
438
+ </div>
439
+
440
+ <div class="form-group">
441
+ <label for="seedDuration" class="form-label">时长(秒)</label>
442
+ <select id="seedDuration" class="form-select">
443
+ <option value="3">3</option>
444
+ <option value="4">4</option>
445
+ <option value="5" selected>5</option>
446
+ <option value="6">6</option>
447
+ <option value="7">7</option>
448
+ <option value="8">8</option>
449
+ <option value="9">9</option>
450
+ <option value="10">10</option>
451
+ <option value="11">11</option>
452
+ <option value="12">12</option>
453
+ </select>
454
+ </div>
455
+
456
+ <div class="form-group">
457
+ <label class="checkbox-label">
458
+ <input type="checkbox" id="t2vCameraFixed">
459
+ <span class="checkmark"></span>
460
+ 固定相机(camera_fixed)
461
+ </label>
462
+ </div>
463
+
464
+ <div class="form-group">
465
+ <label for="t2vSeed" class="form-label">随机种子(-1 为随机)</label>
466
+ <input type="number" id="t2vSeed" class="form-input" value="-1" step="1" min="-1" placeholder="-1">
467
+ </div>
468
+
469
+ <div class="form-group">
470
+ <label class="checkbox-label">
471
+ <input type="checkbox" id="t2vEnableSafety" checked>
472
+ <span class="checkmark"></span>
473
+ 启用内容审查(enable_safety_checker)
474
+ </label>
475
+ <p class="upload-hint">提示:部分平台的 Playground 不允许关闭安全审查;API 可配置。</p>
476
+ </div>
477
+ </div>
478
+ </div>
479
+ </details>
480
+
481
+ <!-- 提交按钮 -->
482
+ <button type="submit" class="submit-button" id="t2vSubmitButton">
483
+ <i class="fas fa-magic"></i>
484
+ 生成视频
485
+ </button>
486
+ </form>
487
+ </div>
488
+ </div>
489
+
490
+ <!-- 视频库面板 -->
491
+ <div id="video-library" class="tab-content">
492
+ <div class="form-container">
493
+ <div class="video-library-header">
494
+ <h3><i class="fas fa-video"></i> 本地视频库</h3>
495
+ <div class="library-controls">
496
+ <button id="refreshLibrary" class="refresh-button">
497
+ <i class="fas fa-sync-alt"></i>
498
+ 刷新
499
+ </button>
500
+ <span id="videoCount" class="video-count">共 0 个视频</span>
501
+ </div>
502
+ </div>
503
+
504
+ <div id="videoLibraryContent" class="video-library-content">
505
+ <div class="loading-library">
506
+ <i class="fas fa-spinner fa-spin"></i>
507
+ 正在加载视频库...
508
+ </div>
509
+ </div>
510
+ </div>
511
+ </div>
512
+
513
+ <!-- 结果显示区域 -->
514
+ <div id="resultContainer" class="result-container" style="display: none;">
515
+ <div class="result-header">
516
+ <h3><i class="fas fa-video"></i> 生成结果</h3>
517
+ <button id="clearResult" class="clear-button">
518
+ <i class="fas fa-times"></i>
519
+ </button>
520
+ </div>
521
+ <div class="result-content">
522
+ <video id="resultVideo" controls class="result-video">
523
+ 您的浏览器不支持视频播放。
524
+ </video>
525
+ <div class="result-actions">
526
+ <a id="downloadLink" class="download-button" download>
527
+ <i class="fas fa-download"></i>
528
+ 下载视频
529
+ </a>
530
+ </div>
531
+ </div>
532
+ </div>
533
+
534
+ <!-- 加载状态 -->
535
+ <div id="loadingContainer" class="loading-container" style="display: none;">
536
+ <div class="loading-content">
537
+ <div class="loading-spinner"></div>
538
+ <h3>正在生成视频...</h3>
539
+ <p id="loadingMessage">请稍候,这可能需要几分钟时间</p>
540
+ <div class="loading-progress">
541
+ <div class="progress-bar">
542
+ <div class="progress-fill" id="progressFill"></div>
543
+ </div>
544
+ </div>
545
+ </div>
546
+ </div>
547
+ </main>
548
+
549
+ <!-- 底部 -->
550
+ <footer class="footer">
551
+ <p>&copy; 2024 FAL AI 视频生成器 | 基于 FAL AI 技术</p>
552
+ </footer>
553
+ </div>
554
+
555
+ <script src="script.js"></script>
556
+ </body>
557
+ </html>
public/script.js ADDED
@@ -0,0 +1,1010 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 全局变量
2
+ let currentTheme = 'light';
3
+ let isGenerating = false;
4
+ let currentApiKey = null;
5
+
6
+ // 初始化
7
+ document.addEventListener('DOMContentLoaded', function() {
8
+ console.log('页面加载完成,开始初始化...');
9
+
10
+ try {
11
+ initializeTheme();
12
+ initializeEventListeners();
13
+ initializeRangeSliders();
14
+ checkApiKeyStatus();
15
+ loadVideoLibrary();
16
+ console.log('初始化完成');
17
+ } catch (error) {
18
+ console.error('初始化失败:', error);
19
+ }
20
+ });
21
+
22
+ // 主题切换功能
23
+ function initializeTheme() {
24
+ const savedTheme = localStorage.getItem('theme') || 'light';
25
+ setTheme(savedTheme);
26
+ }
27
+
28
+ function setTheme(theme) {
29
+ currentTheme = theme;
30
+ document.documentElement.setAttribute('data-theme', theme);
31
+ localStorage.setItem('theme', theme);
32
+
33
+ const themeToggle = document.getElementById('themeToggle');
34
+ if (themeToggle) {
35
+ const icon = themeToggle.querySelector('i');
36
+ if (icon) {
37
+ if (theme === 'dark') {
38
+ icon.className = 'fas fa-sun';
39
+ themeToggle.title = '切换到浅色模式';
40
+ } else {
41
+ icon.className = 'fas fa-moon';
42
+ themeToggle.title = '切换到深色模式';
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ function toggleTheme() {
49
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
50
+ setTheme(newTheme);
51
+ }
52
+
53
+ // API Key 管理功能
54
+ async function checkApiKeyStatus() {
55
+ try {
56
+ const response = await fetch('/api/check-key');
57
+ const data = await response.json();
58
+ updateApiKeyStatus(data);
59
+ } catch (error) {
60
+ console.error('检查 API key 状态失败:', error);
61
+ updateApiKeyStatus({ hasStoredKey: false, hasEnvKey: false, keySource: 'none' });
62
+ }
63
+ }
64
+
65
+ function updateApiKeyStatus(status) {
66
+ const { hasStoredKey, hasEnvKey, keySource } = status;
67
+
68
+ const apiKeyStatus = document.getElementById('apiKeyStatus');
69
+ const apiKeyButton = document.getElementById('apiKeyButton');
70
+ const modalApiKeyStatus = document.getElementById('modalApiKeyStatus');
71
+
72
+ if (apiKeyStatus) {
73
+ if (keySource === 'environment') {
74
+ apiKeyStatus.textContent = '环境变量';
75
+ if (apiKeyButton) apiKeyButton.classList.add('configured');
76
+ if (modalApiKeyStatus) modalApiKeyStatus.textContent = '已配置(来源:环境变量)';
77
+ } else if (keySource === 'stored') {
78
+ apiKeyStatus.textContent = '已保存';
79
+ if (apiKeyButton) apiKeyButton.classList.add('configured');
80
+ if (modalApiKeyStatus) modalApiKeyStatus.textContent = '已配置(来源:本地存储)';
81
+ } else {
82
+ apiKeyStatus.textContent = '未配置';
83
+ if (apiKeyButton) apiKeyButton.classList.remove('configured');
84
+ if (modalApiKeyStatus) modalApiKeyStatus.textContent = '未配置 - 请输入 API 密钥';
85
+ }
86
+ }
87
+ }
88
+
89
+ // 事件监听器初始化
90
+ function initializeEventListeners() {
91
+ console.log('初始化事件监听器...');
92
+
93
+ // 主题切换
94
+ const themeToggle = document.getElementById('themeToggle');
95
+ if (themeToggle) {
96
+ themeToggle.addEventListener('click', toggleTheme);
97
+ console.log('主题切换按钮已绑定');
98
+ }
99
+
100
+ // API Key 管理
101
+ const apiKeyButton = document.getElementById('apiKeyButton');
102
+ const apiKeyModal = document.getElementById('apiKeyModal');
103
+ const closeModal = document.getElementById('closeModal');
104
+
105
+ if (apiKeyButton && apiKeyModal) {
106
+ apiKeyButton.addEventListener('click', () => {
107
+ apiKeyModal.style.display = 'flex';
108
+ checkApiKeyStatus();
109
+ });
110
+ console.log('API Key 按钮已绑定');
111
+ }
112
+
113
+ if (closeModal && apiKeyModal) {
114
+ closeModal.addEventListener('click', () => {
115
+ apiKeyModal.style.display = 'none';
116
+ });
117
+
118
+ // 点击模态框背景关闭
119
+ apiKeyModal.addEventListener('click', (e) => {
120
+ if (e.target === apiKeyModal) {
121
+ apiKeyModal.style.display = 'none';
122
+ }
123
+ });
124
+ console.log('模态框关闭事件已绑定');
125
+ }
126
+
127
+ // API Key 相关按钮
128
+ const toggleApiKeyVisibility = document.getElementById('toggleApiKeyVisibility');
129
+ const saveApiKeyButton = document.getElementById('saveApiKeyButton');
130
+ const testApiKeyButton = document.getElementById('testApiKeyButton');
131
+
132
+ if (toggleApiKeyVisibility) {
133
+ toggleApiKeyVisibility.addEventListener('click', toggleApiKeyVisibilityFunc);
134
+ }
135
+
136
+ if (saveApiKeyButton) {
137
+ saveApiKeyButton.addEventListener('click', saveApiKey);
138
+ }
139
+
140
+ if (testApiKeyButton) {
141
+ testApiKeyButton.addEventListener('click', testApiKey);
142
+ }
143
+
144
+ // ESC 键关闭模态框
145
+ document.addEventListener('keydown', (e) => {
146
+ if (e.key === 'Escape' && apiKeyModal && apiKeyModal.style.display === 'flex') {
147
+ apiKeyModal.style.display = 'none';
148
+ }
149
+ });
150
+
151
+ // 标签页切换
152
+ const tabButtons = document.querySelectorAll('.tab-button');
153
+ tabButtons.forEach(button => {
154
+ button.addEventListener('click', () => switchTab(button.dataset.tab));
155
+ });
156
+ console.log(`${tabButtons.length} 个标签按钮已绑定`);
157
+
158
+ // 图片上传相关
159
+ const imageUploadArea = document.getElementById('imageUploadArea');
160
+ const imageFile = document.getElementById('imageFile');
161
+ const removeImage = document.getElementById('removeImage');
162
+
163
+ if (imageUploadArea && imageFile) {
164
+ imageUploadArea.addEventListener('click', () => imageFile.click());
165
+ imageUploadArea.addEventListener('dragover', handleDragOver);
166
+ imageUploadArea.addEventListener('drop', handleDrop);
167
+ imageUploadArea.addEventListener('dragleave', handleDragLeave);
168
+ imageFile.addEventListener('change', handleImageSelect);
169
+ console.log('图片上传事件已绑定');
170
+ }
171
+
172
+ if (removeImage) {
173
+ removeImage.addEventListener('click', clearImagePreview);
174
+ }
175
+
176
+ // 表单提交
177
+ const imageToVideoForm = document.getElementById('imageToVideoForm');
178
+ const textToVideoForm = document.getElementById('textToVideoForm');
179
+
180
+ if (imageToVideoForm) {
181
+ imageToVideoForm.addEventListener('submit', handleImageToVideoSubmit);
182
+ console.log('图片转视频表单已绑定');
183
+ }
184
+
185
+ if (textToVideoForm) {
186
+ textToVideoForm.addEventListener('submit', handleTextToVideoSubmit);
187
+ console.log('文本转视频表单已绑定');
188
+ }
189
+
190
+ // 结果清除
191
+ const clearResult = document.getElementById('clearResult');
192
+ if (clearResult) {
193
+ clearResult.addEventListener('click', clearResults);
194
+ }
195
+
196
+ // 视频库相关
197
+ const refreshLibrary = document.getElementById('refreshLibrary');
198
+ if (refreshLibrary) {
199
+ refreshLibrary.addEventListener('click', loadVideoLibrary);
200
+ }
201
+
202
+ // 模型选择(文本转视频)
203
+ const t2vModelSelect = document.getElementById('t2vModel');
204
+ if (t2vModelSelect) {
205
+ t2vModelSelect.addEventListener('change', updateModelSettingsVisibility);
206
+ // 初始化时根据默认选择显示对应参数
207
+ updateModelSettingsVisibility();
208
+ console.log('模型选择切换事件已绑定');
209
+ }
210
+
211
+ // 模型选择(图片转视频)
212
+ const i2vModelSelect = document.getElementById('i2vModel');
213
+ if (i2vModelSelect) {
214
+ i2vModelSelect.addEventListener('change', updateI2VModelSettingsVisibility);
215
+ // 初始化时根据默认选择显示对应参数
216
+ updateI2VModelSettingsVisibility();
217
+ console.log('图片转视频模型选择切换事件已绑定');
218
+ }
219
+
220
+ console.log('事件监听器初始化完成');
221
+ }
222
+
223
+ // 范围滑块初始化
224
+ function initializeRangeSliders() {
225
+ const ranges = document.querySelectorAll('.form-range');
226
+ ranges.forEach(range => {
227
+ const valueSpan = document.getElementById(range.id + 'Value');
228
+ if (valueSpan) {
229
+ valueSpan.textContent = range.value;
230
+ range.addEventListener('input', () => {
231
+ valueSpan.textContent = range.value;
232
+ });
233
+ }
234
+ });
235
+ console.log(`${ranges.length} 个滑块已初始化`);
236
+ }
237
+
238
+ // 根据模型选择显示/隐藏对应高级设置
239
+ function updateModelSettingsVisibility() {
240
+ const model = document.getElementById('t2vModel')?.value || 'seedance-pro-fast';
241
+ const wanSettings = document.getElementById('wanSettings');
242
+ const seedSettings = document.getElementById('seedanceSettings');
243
+
244
+ if (wanSettings && seedSettings) {
245
+ if (model === 'wan-v2.2-a14b') {
246
+ wanSettings.style.display = 'block';
247
+ seedSettings.style.display = 'none';
248
+ } else {
249
+ wanSettings.style.display = 'none';
250
+ seedSettings.style.display = 'block';
251
+ }
252
+ }
253
+ }
254
+
255
+ // 根据图片转视频模型选择显示/隐藏对应高级设置
256
+ function updateI2VModelSettingsVisibility() {
257
+ const model = document.getElementById('i2vModel')?.value || 'seedance-pro-fast';
258
+ const wanSettings = document.getElementById('i2vWanSettings');
259
+ const seedSettings = document.getElementById('i2vSeedanceSettings');
260
+
261
+ if (wanSettings && seedSettings) {
262
+ if (model === 'wan-v2.2-a14b') {
263
+ wanSettings.style.display = 'block';
264
+ seedSettings.style.display = 'none';
265
+ } else {
266
+ wanSettings.style.display = 'none';
267
+ seedSettings.style.display = 'block';
268
+ }
269
+ }
270
+ }
271
+
272
+ // API Key 相关函数
273
+ function toggleApiKeyVisibilityFunc() {
274
+ const apiKeyInput = document.getElementById('apiKeyInput');
275
+ const toggleButton = document.getElementById('toggleApiKeyVisibility');
276
+
277
+ if (apiKeyInput && toggleButton) {
278
+ const icon = toggleButton.querySelector('i');
279
+ if (apiKeyInput.type === 'password') {
280
+ apiKeyInput.type = 'text';
281
+ if (icon) icon.className = 'fas fa-eye-slash';
282
+ } else {
283
+ apiKeyInput.type = 'password';
284
+ if (icon) icon.className = 'fas fa-eye';
285
+ }
286
+ }
287
+ }
288
+
289
+ async function saveApiKey() {
290
+ const apiKeyInput = document.getElementById('apiKeyInput');
291
+ const saveApiKeyCheckbox = document.getElementById('saveApiKey');
292
+ const saveApiKeyButton = document.getElementById('saveApiKeyButton');
293
+
294
+ if (!apiKeyInput) return;
295
+
296
+ const apiKey = apiKeyInput.value.trim();
297
+ const shouldSave = saveApiKeyCheckbox ? saveApiKeyCheckbox.checked : true;
298
+
299
+ if (!apiKey) {
300
+ showNotification('请输入 API 密钥', 'error');
301
+ return;
302
+ }
303
+
304
+ // 基本检查
305
+ if (apiKey.length < 10) {
306
+ showNotification('API 密钥长度不足', 'error');
307
+ return;
308
+ }
309
+
310
+ // 显示保存状态
311
+ if (saveApiKeyButton) {
312
+ saveApiKeyButton.disabled = true;
313
+ saveApiKeyButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 保存中...';
314
+ }
315
+
316
+ if (shouldSave) {
317
+ try {
318
+ console.log('正在保存 API 密钥到服务器...');
319
+ const response = await fetch('/api/save-key', {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json' },
322
+ body: JSON.stringify({ apiKey })
323
+ });
324
+
325
+ const result = await response.json();
326
+ console.log('保存结果:', result);
327
+
328
+ if (result.success) {
329
+ showNotification('API 密钥保存成功', 'success');
330
+ checkApiKeyStatus();
331
+ const apiKeyModal = document.getElementById('apiKeyModal');
332
+ if (apiKeyModal) apiKeyModal.style.display = 'none';
333
+ } else {
334
+ showNotification(result.error || '保存失败', 'error');
335
+ console.error('保存失败:', result);
336
+ }
337
+ } catch (error) {
338
+ console.error('保存 API key 失败:', error);
339
+ showNotification('保存失败,请检查网络连接', 'error');
340
+ }
341
+ } else {
342
+ // 临时设置
343
+ currentApiKey = apiKey;
344
+ showNotification('API 密钥已设置(临时)', 'success');
345
+ const apiKeyModal = document.getElementById('apiKeyModal');
346
+ if (apiKeyModal) apiKeyModal.style.display = 'none';
347
+
348
+ const apiKeyStatus = document.getElementById('apiKeyStatus');
349
+ const apiKeyButton = document.getElementById('apiKeyButton');
350
+ if (apiKeyStatus) apiKeyStatus.textContent = '临时设置';
351
+ if (apiKeyButton) apiKeyButton.classList.add('configured');
352
+ }
353
+
354
+ // 恢复按钮状态
355
+ if (saveApiKeyButton) {
356
+ saveApiKeyButton.disabled = false;
357
+ saveApiKeyButton.innerHTML = '<i class="fas fa-save"></i> 保存设置';
358
+ }
359
+ }
360
+
361
+ async function testApiKey() {
362
+ const testApiKeyButton = document.getElementById('testApiKeyButton');
363
+ const apiKeyInput = document.getElementById('apiKeyInput');
364
+
365
+ if (!testApiKeyButton) return;
366
+
367
+ const apiKey = apiKeyInput ? apiKeyInput.value.trim() : null;
368
+
369
+ testApiKeyButton.disabled = true;
370
+ testApiKeyButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 连接中...';
371
+
372
+ try {
373
+ const response = await fetch('/api/test-key', {
374
+ method: 'POST',
375
+ headers: { 'Content-Type': 'application/json' },
376
+ body: JSON.stringify({ apiKey })
377
+ });
378
+
379
+ const result = await response.json();
380
+
381
+ if (result.success) {
382
+ showNotification(`✅ ${result.message}`, 'success');
383
+ if (result.note) {
384
+ setTimeout(() => {
385
+ showNotification(result.note, 'info');
386
+ }, 1500);
387
+ }
388
+ } else {
389
+ showNotification(result.error || 'API 密钥格式验证失败', 'error');
390
+
391
+ // 显示具体的格式问题
392
+ if (result.tips && result.tips.length > 0) {
393
+ console.error('格式问题:', result.tips);
394
+ setTimeout(() => {
395
+ result.tips.forEach((tip, index) => {
396
+ setTimeout(() => {
397
+ showNotification(tip, 'warning');
398
+ }, index * 1000);
399
+ });
400
+ }, 1000);
401
+ }
402
+ }
403
+ } catch (error) {
404
+ console.error('测试 API key 失败:', error);
405
+ showNotification('网��连接失败,请重试', 'error');
406
+ } finally {
407
+ testApiKeyButton.disabled = false;
408
+ testApiKeyButton.innerHTML = '<i class="fas fa-plug"></i> 测试连接';
409
+ }
410
+ }
411
+
412
+ // 标签页切换
413
+ function switchTab(tabId) {
414
+ const tabButtons = document.querySelectorAll('.tab-button');
415
+ const tabContents = document.querySelectorAll('.tab-content');
416
+
417
+ tabButtons.forEach(btn => {
418
+ btn.classList.toggle('active', btn.dataset.tab === tabId);
419
+ });
420
+
421
+ tabContents.forEach(content => {
422
+ content.classList.toggle('active', content.id === tabId);
423
+ });
424
+
425
+ clearResults();
426
+ }
427
+
428
+ // 图片处理函数
429
+ function handleDragOver(e) {
430
+ e.preventDefault();
431
+ const imageUploadArea = document.getElementById('imageUploadArea');
432
+ if (imageUploadArea) imageUploadArea.classList.add('dragover');
433
+ }
434
+
435
+ function handleDragLeave(e) {
436
+ e.preventDefault();
437
+ const imageUploadArea = document.getElementById('imageUploadArea');
438
+ if (imageUploadArea) imageUploadArea.classList.remove('dragover');
439
+ }
440
+
441
+ function handleDrop(e) {
442
+ e.preventDefault();
443
+ const imageUploadArea = document.getElementById('imageUploadArea');
444
+ if (imageUploadArea) imageUploadArea.classList.remove('dragover');
445
+
446
+ const files = e.dataTransfer.files;
447
+ if (files.length > 0 && files[0].type.startsWith('image/')) {
448
+ handleImageFile(files[0]);
449
+ }
450
+ }
451
+
452
+ function handleImageSelect(e) {
453
+ const file = e.target.files[0];
454
+ if (file && file.type.startsWith('image/')) {
455
+ handleImageFile(file);
456
+ }
457
+ }
458
+
459
+ function handleImageFile(file) {
460
+ if (file.size > 50 * 1024 * 1024) {
461
+ showNotification('图片文件大小不能超过 50MB', 'error');
462
+ return;
463
+ }
464
+
465
+ const reader = new FileReader();
466
+ reader.onload = function(e) {
467
+ const previewImg = document.getElementById('previewImg');
468
+ const imagePreview = document.getElementById('imagePreview');
469
+ const uploadPlaceholder = document.querySelector('.upload-placeholder');
470
+ const imageUrl = document.getElementById('imageUrl');
471
+
472
+ if (previewImg) previewImg.src = e.target.result;
473
+ if (imagePreview) imagePreview.style.display = 'block';
474
+ if (uploadPlaceholder) uploadPlaceholder.style.display = 'none';
475
+ if (imageUrl) imageUrl.value = '';
476
+ };
477
+ reader.readAsDataURL(file);
478
+ }
479
+
480
+ function clearImagePreview() {
481
+ const imagePreview = document.getElementById('imagePreview');
482
+ const uploadPlaceholder = document.querySelector('.upload-placeholder');
483
+ const imageFile = document.getElementById('imageFile');
484
+ const imageUrl = document.getElementById('imageUrl');
485
+
486
+ if (imagePreview) imagePreview.style.display = 'none';
487
+ if (uploadPlaceholder) uploadPlaceholder.style.display = 'block';
488
+ if (imageFile) imageFile.value = '';
489
+ if (imageUrl) imageUrl.value = '';
490
+ }
491
+
492
+ // 表单提交处理
493
+ async function handleImageToVideoSubmit(e) {
494
+ e.preventDefault();
495
+ // 并发生成允许,不阻塞生成按钮
496
+
497
+ const prompt = document.getElementById('i2vPrompt')?.value.trim();
498
+ const imageUrl = document.getElementById('imageUrl')?.value.trim();
499
+ const imageFile = document.getElementById('imageFile');
500
+ const model = document.getElementById('i2vModel')?.value || 'seedance-pro-fast';
501
+
502
+ if (!prompt) {
503
+ showNotification('请输入文本描述', 'error');
504
+ return;
505
+ }
506
+
507
+ if (!imageFile?.files[0] && !imageUrl) {
508
+ showNotification('请选择图片或输入图片URL', 'error');
509
+ return;
510
+ }
511
+
512
+ const formData = new FormData();
513
+
514
+ if (imageFile?.files[0]) {
515
+ formData.append('image', imageFile.files[0]);
516
+ } else {
517
+ formData.append('image_url', imageUrl);
518
+ }
519
+
520
+ const apiKey = currentApiKey;
521
+ if (apiKey) {
522
+ formData.append('userApiKey', apiKey);
523
+ }
524
+
525
+ formData.append('prompt', prompt);
526
+ formData.append('model', model);
527
+
528
+ if (model === 'seedance-pro-fast') {
529
+ // Seedance 1.0 Pro Fast 所需参数
530
+ formData.append('aspect_ratio', document.getElementById('i2vSeedAspectRatio')?.value || 'auto');
531
+ formData.append('resolution', document.getElementById('i2vSeedResolution')?.value || '1080p');
532
+ formData.append('duration', document.getElementById('i2vSeedDuration')?.value || '5');
533
+ formData.append('camera_fixed', document.getElementById('i2vCameraFixed')?.checked ? 'true' : 'false');
534
+ formData.append('seed', (document.getElementById('i2vSeedValue')?.value ?? '-1').toString());
535
+ formData.append('enable_safety_checker', document.getElementById('i2vEnableSafety')?.checked ? 'true' : 'false');
536
+ } else {
537
+ // WAN v2.2-a14b 参数(保持原逻辑)
538
+ formData.append('negative_prompt', document.getElementById('i2vNegativePrompt')?.value || '');
539
+ formData.append('num_frames', document.getElementById('i2vFrames')?.value || '81');
540
+ formData.append('frames_per_second', document.getElementById('i2vFps')?.value || '16');
541
+ formData.append('resolution', document.getElementById('i2vResolution')?.value || '720p');
542
+ formData.append('aspect_ratio', document.getElementById('i2vAspectRatio')?.value || 'auto');
543
+ formData.append('video_quality', document.getElementById('i2vQuality')?.value || 'high');
544
+ formData.append('enable_safety_checker', document.getElementById('i2vDisableSafety')?.checked ? 'false' : 'true');
545
+ }
546
+
547
+ await generateVideo('/api/image-to-video', formData);
548
+ }
549
+
550
+ async function handleTextToVideoSubmit(e) {
551
+ e.preventDefault();
552
+ // 并发生成允许,不阻塞生成按钮
553
+
554
+ const prompt = document.getElementById('t2vPrompt')?.value.trim();
555
+ if (!prompt) {
556
+ showNotification('请输入文本描述', 'error');
557
+ return;
558
+ }
559
+
560
+ const model = document.getElementById('t2vModel')?.value || 'seedance-pro-fast';
561
+ const apiKey = currentApiKey;
562
+
563
+ if (model === 'seedance-pro-fast') {
564
+ // Bytedance Seedance 1.0 Pro Fast 入参
565
+ const requestData = {
566
+ prompt: prompt,
567
+ aspect_ratio: document.getElementById('seedAspectRatio')?.value || '16:9',
568
+ resolution: document.getElementById('seedResolution')?.value || '1080p',
569
+ duration: document.getElementById('seedDuration')?.value || '5',
570
+ camera_fixed: !!document.getElementById('t2vCameraFixed')?.checked,
571
+ seed: parseInt(document.getElementById('t2vSeed')?.value ?? '-1', 10),
572
+ enable_safety_checker: !!document.getElementById('t2vEnableSafety')?.checked,
573
+ model: 'seedance-pro-fast'
574
+ };
575
+
576
+ if (apiKey) {
577
+ requestData.userApiKey = apiKey;
578
+ }
579
+
580
+ await generateVideo('/api/text-to-video', requestData, 'json');
581
+ return;
582
+ }
583
+
584
+ // WAN v2.2-a14b 入参(保持原逻辑)
585
+ const requestData = {
586
+ prompt: prompt,
587
+ negative_prompt: document.getElementById('t2vNegativePrompt')?.value || '',
588
+ num_frames: parseInt(document.getElementById('t2vFrames')?.value || '81', 10),
589
+ frames_per_second: parseInt(document.getElementById('t2vFps')?.value || '16', 10),
590
+ resolution: document.getElementById('t2vResolution')?.value || '720p',
591
+ aspect_ratio: document.getElementById('t2vAspectRatio')?.value || '16:9',
592
+ video_quality: document.getElementById('t2vQuality')?.value || 'high',
593
+ enable_safety_checker: document.getElementById('t2vDisableSafety')?.checked ? false : true,
594
+ model: 'wan-v2.2-a14b'
595
+ };
596
+
597
+ if (apiKey) {
598
+ requestData.userApiKey = apiKey;
599
+ }
600
+
601
+ await generateVideo('/api/text-to-video', requestData, 'json');
602
+ }
603
+
604
+ // 视频生成
605
+ async function generateVideo(endpoint, data, contentType = 'form') {
606
+ // 在生成栏中创建队列项并显示进度,允许并发生成
607
+ const queueList = document.getElementById('queueList');
608
+ const queueCount = document.getElementById('queueCount');
609
+
610
+ if (!queueList) {
611
+ // 兼容旧页面:如果没有队列容器,直接执行旧逻辑的通知与结果展示
612
+ try {
613
+ const options = { method: 'POST' };
614
+ if (contentType === 'json') {
615
+ options.headers = { 'Content-Type': 'application/json' };
616
+ options.body = JSON.stringify(data);
617
+ } else {
618
+ options.body = data;
619
+ }
620
+ const response = await fetch(endpoint, options);
621
+ const result = await response.json();
622
+ if (result.success && result.data && result.data.video) {
623
+ showResult(result.data.video.url, result.data.prompt);
624
+ showNotification('视频生成成功!', 'success');
625
+ } else {
626
+ throw new Error(result.error || '视频生成失败');
627
+ }
628
+ } catch (error) {
629
+ console.error('生成错误:', error);
630
+ showNotification(error.message || '生成失败,请重试', 'error');
631
+ }
632
+ return;
633
+ }
634
+
635
+ // 若为空队列占位,先移除
636
+ if (queueList.classList.contains('empty')) {
637
+ queueList.classList.remove('empty');
638
+ queueList.innerHTML = '';
639
+ }
640
+
641
+ const taskId = `task-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
642
+ const title = endpoint.includes('image') ? '图片转视频' : '文本转视频';
643
+ const promptText = (() => {
644
+ if (typeof data === 'object' && contentType === 'json') {
645
+ return data.prompt ?? '';
646
+ }
647
+ // FormData 场景:直接从页面读取
648
+ const domId = endpoint.includes('image') ? 'i2vPrompt' : 't2vPrompt';
649
+ return document.getElementById(domId)?.value?.trim() ?? '';
650
+ })();
651
+
652
+ const item = document.createElement('div');
653
+ item.className = 'queue-item';
654
+ item.id = taskId;
655
+ item.innerHTML = `
656
+ <div class="info">
657
+ <div class="title">${title}</div>
658
+ <div class="prompt">${escapeHtml(promptText)}</div>
659
+ <div class="meta">${new Date().toLocaleString('zh-CN')}</div>
660
+ </div>
661
+ <div class="queue-progress">
662
+ <div class="progress-bar"><div class="progress-fill" id="${taskId}-progress"></div></div>
663
+ <div class="queue-status running" id="${taskId}-status">运行中</div>
664
+ </div>
665
+ `;
666
+ queueList.appendChild(item);
667
+
668
+ // 更新队列计数
669
+ if (queueCount) {
670
+ const count = queueList.querySelectorAll('.queue-item').length;
671
+ queueCount.textContent = `${count} 任务`;
672
+ }
673
+
674
+ // 每个任务独立的进度模拟(服务端暂不提供实时进度回传)
675
+ let progress = 0;
676
+ const progressEl = () => document.getElementById(`${taskId}-progress`);
677
+ const statusEl = () => document.getElementById(`${taskId}-status`);
678
+ const progressInterval = setInterval(() => {
679
+ progress += Math.random() * 15;
680
+ if (progress > 90) progress = 90;
681
+ if (progressEl()) progressEl().style.width = progress + '%';
682
+ }, 900);
683
+
684
+ try {
685
+ const options = { method: 'POST' };
686
+ if (contentType === 'json') {
687
+ options.headers = { 'Content-Type': 'application/json' };
688
+ options.body = JSON.stringify(data);
689
+ } else {
690
+ options.body = data;
691
+ }
692
+
693
+ const response = await fetch(endpoint, options);
694
+ const result = await response.json();
695
+
696
+ if (result.success && result.data && result.data.video) {
697
+ clearInterval(progressInterval);
698
+ if (progressEl()) progressEl().style.width = '100%';
699
+ if (statusEl()) {
700
+ statusEl().className = 'queue-status done';
701
+ statusEl().textContent = '完成';
702
+ }
703
+ showResult(result.data.video.url, result.data.prompt);
704
+ showNotification('视频生成成功!', 'success');
705
+ } else {
706
+ throw new Error(result.error || '视频生成失败');
707
+ }
708
+ } catch (error) {
709
+ clearInterval(progressInterval);
710
+ console.error('生成错误:', error);
711
+ if (statusEl()) {
712
+ statusEl().className = 'queue-status error';
713
+ statusEl().textContent = '错误';
714
+ }
715
+ if (error.message.includes('API') || error.message.includes('密钥')) {
716
+ showNotification('请先配置 API 密钥', 'error');
717
+ setTimeout(() => {
718
+ const apiKeyModal = document.getElementById('apiKeyModal');
719
+ if (apiKeyModal) apiKeyModal.style.display = 'flex';
720
+ }, 1000);
721
+ } else {
722
+ showNotification(error.message || '生成失败,请重试', 'error');
723
+ }
724
+ }
725
+
726
+ function escapeHtml(str) {
727
+ const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
728
+ return String(str || '').replace(/[&<>"']/g, s => map[s]);
729
+ }
730
+ }
731
+
732
+ // 加载状态
733
+ function showLoading() {
734
+ // 不再显示全屏遮罩,也不禁用按钮,避免占用全屏和阻塞生成键
735
+ const loadingContainer = document.getElementById('loadingContainer');
736
+ if (loadingContainer) loadingContainer.style.display = 'none';
737
+ }
738
+
739
+ function hideLoading() {
740
+ // 保持按钮可用,不做处理
741
+ const loadingContainer = document.getElementById('loadingContainer');
742
+ if (loadingContainer) loadingContainer.style.display = 'none';
743
+ }
744
+
745
+ function simulateProgress() {
746
+ // 已改为每个任务独立的进度显示,此处不再使用全局模拟
747
+ return;
748
+ }
749
+
750
+ // 结果显示
751
+ function showResult(videoUrl, prompt) {
752
+ const resultVideo = document.getElementById('resultVideo');
753
+ const downloadLink = document.getElementById('downloadLink');
754
+ const resultContainer = document.getElementById('resultContainer');
755
+
756
+ if (resultVideo) resultVideo.src = videoUrl;
757
+ if (downloadLink) {
758
+ downloadLink.href = videoUrl;
759
+ downloadLink.download = `generated-video-${Date.now()}.mp4`;
760
+ }
761
+ if (resultContainer) {
762
+ resultContainer.style.display = 'block';
763
+ resultContainer.scrollIntoView({ behavior: 'smooth' });
764
+ }
765
+ }
766
+
767
+ function clearResults() {
768
+ const resultContainer = document.getElementById('resultContainer');
769
+ const resultVideo = document.getElementById('resultVideo');
770
+ const downloadLink = document.getElementById('downloadLink');
771
+
772
+ if (resultContainer) resultContainer.style.display = 'none';
773
+ if (resultVideo) resultVideo.src = '';
774
+ if (downloadLink) downloadLink.href = '';
775
+ }
776
+
777
+ // 通知系统
778
+ function showNotification(message, type = 'info') {
779
+ const notification = document.createElement('div');
780
+ notification.className = `notification notification-${type}`;
781
+ notification.innerHTML = `
782
+ <div class="notification-content">
783
+ <i class="fas ${getNotificationIcon(type)}"></i>
784
+ <span>${message}</span>
785
+ </div>
786
+ `;
787
+
788
+ notification.style.cssText = `
789
+ position: fixed;
790
+ top: 20px;
791
+ right: 20px;
792
+ background: ${getNotificationColor(type)};
793
+ color: white;
794
+ padding: 1rem 1.5rem;
795
+ border-radius: 0.5rem;
796
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
797
+ z-index: 1001;
798
+ transform: translateX(100%);
799
+ transition: transform 0.3s ease;
800
+ max-width: 400px;
801
+ `;
802
+
803
+ document.body.appendChild(notification);
804
+
805
+ setTimeout(() => {
806
+ notification.style.transform = 'translateX(0)';
807
+ }, 100);
808
+
809
+ setTimeout(() => {
810
+ notification.style.transform = 'translateX(100%)';
811
+ setTimeout(() => {
812
+ if (document.body.contains(notification)) {
813
+ document.body.removeChild(notification);
814
+ }
815
+ }, 300);
816
+ }, 4000);
817
+ }
818
+
819
+ function getNotificationIcon(type) {
820
+ switch (type) {
821
+ case 'success': return 'fa-check-circle';
822
+ case 'error': return 'fa-exclamation-circle';
823
+ case 'warning': return 'fa-exclamation-triangle';
824
+ default: return 'fa-info-circle';
825
+ }
826
+ }
827
+
828
+ function getNotificationColor(type) {
829
+ switch (type) {
830
+ case 'success': return '#3b82f6';
831
+ case 'error': return '#ef4444';
832
+ case 'warning': return '#f59e0b';
833
+ default: return '#3b82f6';
834
+ }
835
+ }
836
+
837
+ // 视频库功能
838
+ async function loadVideoLibrary() {
839
+ const videoLibraryContent = document.getElementById('videoLibraryContent');
840
+ const videoCount = document.getElementById('videoCount');
841
+
842
+ if (!videoLibraryContent) return;
843
+
844
+ // 显示加载状态
845
+ videoLibraryContent.innerHTML = `
846
+ <div class="loading-library">
847
+ <i class="fas fa-spinner fa-spin"></i>
848
+ 正在加载视频库...
849
+ </div>
850
+ `;
851
+
852
+ try {
853
+ const response = await fetch('/api/videos');
854
+ const data = await response.json();
855
+
856
+ if (data.videos && data.videos.length > 0) {
857
+ displayVideoLibrary(data.videos);
858
+ if (videoCount) {
859
+ videoCount.textContent = `共 ${data.videos.length} 个视频`;
860
+ }
861
+ } else {
862
+ displayEmptyLibrary();
863
+ if (videoCount) {
864
+ videoCount.textContent = '共 0 个视频';
865
+ }
866
+ }
867
+ } catch (error) {
868
+ console.error('加载视频库失败:', error);
869
+ videoLibraryContent.innerHTML = `
870
+ <div class="empty-library">
871
+ <i class="fas fa-exclamation-triangle"></i>
872
+ <p>加载视频库失败</p>
873
+ <button onclick="loadVideoLibrary()" class="refresh-button">重试</button>
874
+ </div>
875
+ `;
876
+ }
877
+ }
878
+
879
+ function displayVideoLibrary(videos) {
880
+ const videoLibraryContent = document.getElementById('videoLibraryContent');
881
+
882
+ const videoGrid = document.createElement('div');
883
+ videoGrid.className = 'video-grid';
884
+
885
+ videos.forEach(video => {
886
+ const videoItem = createVideoItem(video);
887
+ videoGrid.appendChild(videoItem);
888
+ });
889
+
890
+ videoLibraryContent.innerHTML = '';
891
+ videoLibraryContent.appendChild(videoGrid);
892
+ }
893
+
894
+ function createVideoItem(video) {
895
+ const item = document.createElement('div');
896
+ item.className = 'video-item';
897
+
898
+ const fileSize = formatFileSize(video.size);
899
+ const createdDate = new Date(video.created).toLocaleString('zh-CN');
900
+
901
+ item.innerHTML = `
902
+ <video class="video-preview" controls preload="metadata">
903
+ <source src="/${video.path}" type="video/mp4">
904
+ 您的浏览器不支持视频播放。
905
+ </video>
906
+ <div class="video-info">
907
+ <div class="video-filename">${video.filename}</div>
908
+ <div class="video-meta">
909
+ <span>${createdDate}</span>
910
+ <span class="video-size">${fileSize}</span>
911
+ </div>
912
+ <div class="video-actions">
913
+ <button class="video-action-btn play-video-btn" onclick="playVideoInModal('/${video.path}')">
914
+ <i class="fas fa-play"></i>
915
+ 播放
916
+ </button>
917
+ <button class="video-action-btn download-video-btn" onclick="downloadVideo('/${video.path}', '${video.filename}')">
918
+ <i class="fas fa-download"></i>
919
+ 下载
920
+ </button>
921
+ </div>
922
+ </div>
923
+ `;
924
+
925
+ return item;
926
+ }
927
+
928
+ function displayEmptyLibrary() {
929
+ const videoLibraryContent = document.getElementById('videoLibraryContent');
930
+ videoLibraryContent.innerHTML = `
931
+ <div class="empty-library">
932
+ <i class="fas fa-video"></i>
933
+ <p>暂无视频</p>
934
+ <p class="upload-hint">生成的视频会自动保存到这里</p>
935
+ </div>
936
+ `;
937
+ }
938
+
939
+ function formatFileSize(bytes) {
940
+ if (bytes === 0) return '0 Bytes';
941
+ const k = 1024;
942
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
943
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
944
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
945
+ }
946
+
947
+ function playVideoInModal(videoPath) {
948
+ // 在结果容器中播放视频
949
+ const resultVideo = document.getElementById('resultVideo');
950
+ const resultContainer = document.getElementById('resultContainer');
951
+ const downloadLink = document.getElementById('downloadLink');
952
+
953
+ if (resultVideo && resultContainer && downloadLink) {
954
+ resultVideo.src = videoPath;
955
+ downloadLink.href = videoPath;
956
+ downloadLink.download = videoPath.split('/').pop();
957
+ resultContainer.style.display = 'block';
958
+ resultContainer.scrollIntoView({ behavior: 'smooth' });
959
+ }
960
+ }
961
+
962
+ function downloadVideo(videoPath, filename) {
963
+ const link = document.createElement('a');
964
+ link.href = videoPath;
965
+ link.download = filename;
966
+ document.body.appendChild(link);
967
+ link.click();
968
+ document.body.removeChild(link);
969
+ showNotification('开始下载视频', 'success');
970
+ }
971
+
972
+ // 修改 showResult 函数,生成视频后自动刷新视频库
973
+ function showResult(videoUrl, prompt) {
974
+ const resultVideo = document.getElementById('resultVideo');
975
+ const downloadLink = document.getElementById('downloadLink');
976
+ const resultContainer = document.getElementById('resultContainer');
977
+
978
+ if (resultVideo) resultVideo.src = videoUrl;
979
+ if (downloadLink) {
980
+ downloadLink.href = videoUrl;
981
+ downloadLink.download = `generated-video-${Date.now()}.mp4`;
982
+ }
983
+ if (resultContainer) {
984
+ resultContainer.style.display = 'block';
985
+ resultContainer.scrollIntoView({ behavior: 'smooth' });
986
+ }
987
+
988
+ // 刷新视频库
989
+ setTimeout(() => {
990
+ loadVideoLibrary();
991
+ }, 2000);
992
+ }
993
+
994
+ // 错误处理
995
+ window.addEventListener('error', function(e) {
996
+ console.error('全局错误:', e.error);
997
+ if (isGenerating) {
998
+ hideLoading();
999
+ showNotification('发生未知错误,请重试', 'error');
1000
+ isGenerating = false;
1001
+ }
1002
+ });
1003
+
1004
+ window.addEventListener('online', () => {
1005
+ showNotification('网络连接已恢复', 'success');
1006
+ });
1007
+
1008
+ window.addEventListener('offline', () => {
1009
+ showNotification('网络连接已断开', 'warning');
1010
+ });
public/style.css ADDED
@@ -0,0 +1,1136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CSS 变量定义 */
2
+ :root {
3
+ /* 浅色主题 */
4
+ --bg-primary: #ffffff;
5
+ --bg-secondary: #f8fafc;
6
+ --bg-tertiary: #f1f5f9;
7
+ --text-primary: #1e293b;
8
+ --text-secondary: #64748b;
9
+ --text-muted: #94a3b8;
10
+ --border-color: #e2e8f0;
11
+ --accent-primary: #3b82f6;
12
+ --accent-secondary: #1d4ed8;
13
+ --accent-hover: #2563eb;
14
+ --success-color: #6366f1;
15
+ --error-color: #ef4444;
16
+ --warning-color: #f59e0b;
17
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
18
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
19
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
20
+ --gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
21
+ --gradient-secondary: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
22
+ }
23
+
24
+ /* 深色主题 */
25
+ [data-theme="dark"] {
26
+ --bg-primary: #0f172a;
27
+ --bg-secondary: #1e293b;
28
+ --bg-tertiary: #334155;
29
+ --text-primary: #f8fafc;
30
+ --text-secondary: #cbd5e1;
31
+ --text-muted: #64748b;
32
+ --border-color: #475569;
33
+ --accent-primary: #60a5fa;
34
+ --accent-secondary: #3b82f6;
35
+ --accent-hover: #2563eb;
36
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
37
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
38
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
39
+ }
40
+
41
+ /* 基础样式重置 */
42
+ * {
43
+ margin: 0;
44
+ padding: 0;
45
+ box-sizing: border-box;
46
+ }
47
+
48
+ body {
49
+ font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
50
+ background-color: var(--bg-secondary);
51
+ color: var(--text-primary);
52
+ line-height: 1.6;
53
+ transition: background-color 0.3s ease, color 0.3s ease;
54
+ }
55
+
56
+ .container {
57
+ min-height: 100vh;
58
+ display: flex;
59
+ flex-direction: column;
60
+ }
61
+
62
+ /* 头部样式 */
63
+ .header {
64
+ background: var(--bg-primary);
65
+ border-bottom: 1px solid var(--border-color);
66
+ box-shadow: var(--shadow-sm);
67
+ position: sticky;
68
+ top: 0;
69
+ z-index: 100;
70
+ }
71
+
72
+ .header-content {
73
+ max-width: 1200px;
74
+ margin: 0 auto;
75
+ padding: 1rem 2rem;
76
+ display: flex;
77
+ justify-content: space-between;
78
+ align-items: center;
79
+ }
80
+
81
+ .title {
82
+ font-size: 1.75rem;
83
+ font-weight: 700;
84
+ background: var(--gradient-primary);
85
+ background-clip: text;
86
+ -webkit-background-clip: text;
87
+ -webkit-text-fill-color: transparent;
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 0.75rem;
91
+ }
92
+
93
+ .title i {
94
+ color: var(--accent-primary);
95
+ -webkit-text-fill-color: var(--accent-primary);
96
+ }
97
+
98
+ .header-controls {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 1rem;
102
+ }
103
+
104
+ .api-key-button,
105
+ .theme-toggle {
106
+ background: var(--bg-tertiary);
107
+ border: 1px solid var(--border-color);
108
+ border-radius: 0.5rem;
109
+ padding: 0.75rem;
110
+ cursor: pointer;
111
+ transition: all 0.3s ease;
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 0.5rem;
115
+ color: var(--text-secondary);
116
+ }
117
+
118
+ .api-key-button:hover,
119
+ .theme-toggle:hover {
120
+ background: var(--accent-primary);
121
+ color: white;
122
+ transform: translateY(-1px);
123
+ }
124
+
125
+ .api-key-button.configured {
126
+ background: var(--success-color);
127
+ color: white;
128
+ }
129
+
130
+ /* 模态框样式 */
131
+ .modal {
132
+ position: fixed;
133
+ top: 0;
134
+ left: 0;
135
+ width: 100%;
136
+ height: 100%;
137
+ background: rgba(0, 0, 0, 0.7);
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ z-index: 1000;
142
+ backdrop-filter: blur(4px);
143
+ }
144
+
145
+ .modal-content {
146
+ background: var(--bg-primary);
147
+ border-radius: 1rem;
148
+ max-width: 500px;
149
+ width: 90%;
150
+ max-height: 90vh;
151
+ overflow-y: auto;
152
+ box-shadow: var(--shadow-lg);
153
+ border: 1px solid var(--border-color);
154
+ }
155
+
156
+ .modal-header {
157
+ padding: 1.5rem;
158
+ border-bottom: 1px solid var(--border-color);
159
+ display: flex;
160
+ justify-content: space-between;
161
+ align-items: center;
162
+ }
163
+
164
+ .modal-header h3 {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 0.5rem;
168
+ color: var(--text-primary);
169
+ }
170
+
171
+ .close-button {
172
+ background: none;
173
+ border: none;
174
+ font-size: 1.25rem;
175
+ cursor: pointer;
176
+ color: var(--text-muted);
177
+ padding: 0.5rem;
178
+ border-radius: 0.25rem;
179
+ transition: all 0.3s ease;
180
+ }
181
+
182
+ .close-button:hover {
183
+ background: var(--error-color);
184
+ color: white;
185
+ }
186
+
187
+ .modal-body {
188
+ padding: 1.5rem;
189
+ }
190
+
191
+ .api-key-info {
192
+ background: var(--bg-tertiary);
193
+ border-radius: 0.5rem;
194
+ padding: 1rem;
195
+ margin-bottom: 1.5rem;
196
+ border-left: 4px solid var(--accent-primary);
197
+ }
198
+
199
+ .api-key-hint {
200
+ color: var(--text-secondary);
201
+ font-size: 0.875rem;
202
+ margin-top: 0.5rem;
203
+ }
204
+
205
+ .api-key-hint a {
206
+ color: var(--accent-primary);
207
+ text-decoration: none;
208
+ }
209
+
210
+ .api-key-hint a:hover {
211
+ text-decoration: underline;
212
+ }
213
+
214
+ .api-key-warning {
215
+ color: var(--accent-primary);
216
+ font-size: 0.875rem;
217
+ margin-top: 0.5rem;
218
+ padding: 0.75rem;
219
+ background: rgba(59, 130, 246, 0.1);
220
+ border-radius: 0.375rem;
221
+ border: 1px solid rgba(59, 130, 246, 0.2);
222
+ }
223
+
224
+ .api-key-warning i {
225
+ color: var(--accent-primary);
226
+ }
227
+
228
+ .api-key-input-group {
229
+ position: relative;
230
+ display: flex;
231
+ }
232
+
233
+ .api-key-input-group .form-input {
234
+ flex: 1;
235
+ padding-right: 3rem;
236
+ }
237
+
238
+ .toggle-visibility {
239
+ position: absolute;
240
+ right: 0.5rem;
241
+ top: 50%;
242
+ transform: translateY(-50%);
243
+ background: none;
244
+ border: none;
245
+ cursor: pointer;
246
+ color: var(--text-muted);
247
+ padding: 0.5rem;
248
+ border-radius: 0.25rem;
249
+ transition: all 0.3s ease;
250
+ }
251
+
252
+ .toggle-visibility:hover {
253
+ color: var(--accent-primary);
254
+ }
255
+
256
+ .api-key-options {
257
+ margin: 1rem 0;
258
+ }
259
+
260
+ .checkbox-label {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 0.75rem;
264
+ cursor: pointer;
265
+ color: var(--text-secondary);
266
+ }
267
+
268
+ .checkbox-label input[type="checkbox"] {
269
+ display: none;
270
+ }
271
+
272
+ .checkmark {
273
+ width: 20px;
274
+ height: 20px;
275
+ border: 2px solid var(--border-color);
276
+ border-radius: 0.25rem;
277
+ position: relative;
278
+ transition: all 0.3s ease;
279
+ }
280
+
281
+ .checkbox-label input[type="checkbox"]:checked + .checkmark {
282
+ background: var(--accent-primary);
283
+ border-color: var(--accent-primary);
284
+ }
285
+
286
+ .checkbox-label input[type="checkbox"]:checked + .checkmark::after {
287
+ content: '✓';
288
+ position: absolute;
289
+ color: white;
290
+ font-size: 0.875rem;
291
+ top: 50%;
292
+ left: 50%;
293
+ transform: translate(-50%, -50%);
294
+ }
295
+
296
+ .modal-actions {
297
+ display: flex;
298
+ gap: 1rem;
299
+ margin-top: 1.5rem;
300
+ }
301
+
302
+ .save-button,
303
+ .test-button {
304
+ flex: 1;
305
+ padding: 0.75rem 1.5rem;
306
+ border: none;
307
+ border-radius: 0.5rem;
308
+ cursor: pointer;
309
+ font-weight: 600;
310
+ transition: all 0.3s ease;
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: center;
314
+ gap: 0.5rem;
315
+ }
316
+
317
+ .save-button {
318
+ background: var(--accent-primary);
319
+ color: white;
320
+ }
321
+
322
+ .save-button:hover {
323
+ background: var(--accent-hover);
324
+ }
325
+
326
+ .test-button {
327
+ background: var(--accent-primary);
328
+ color: white;
329
+ }
330
+
331
+ .test-button:hover {
332
+ background: var(--accent-hover);
333
+ }
334
+
335
+ /* 主要内容 */
336
+ .main {
337
+ flex: 1;
338
+ max-width: 1200px;
339
+ margin: 0 auto;
340
+ padding: 2rem;
341
+ width: 100%;
342
+ }
343
+
344
+ /* 标签页 */
345
+ .tabs {
346
+ display: flex;
347
+ gap: 0.5rem;
348
+ margin-bottom: 2rem;
349
+ background: var(--bg-primary);
350
+ padding: 0.5rem;
351
+ border-radius: 0.75rem;
352
+ box-shadow: var(--shadow-sm);
353
+ }
354
+
355
+ .tab-button {
356
+ flex: 1;
357
+ background: transparent;
358
+ border: none;
359
+ padding: 1rem 1.5rem;
360
+ border-radius: 0.5rem;
361
+ cursor: pointer;
362
+ transition: all 0.3s ease;
363
+ color: var(--text-secondary);
364
+ font-weight: 500;
365
+ display: flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ gap: 0.5rem;
369
+ }
370
+
371
+ .tab-button:hover {
372
+ background: var(--bg-tertiary);
373
+ color: var(--text-primary);
374
+ }
375
+
376
+ .tab-button.active {
377
+ background: var(--accent-primary);
378
+ color: white;
379
+ box-shadow: var(--shadow-md);
380
+ }
381
+
382
+ /* 标签内容 */
383
+ .tab-content {
384
+ display: none;
385
+ }
386
+
387
+ .tab-content.active {
388
+ display: block;
389
+ }
390
+
391
+ /* 表单容器 */
392
+ .form-container {
393
+ background: var(--bg-primary);
394
+ border-radius: 1rem;
395
+ padding: 2rem;
396
+ box-shadow: var(--shadow-lg);
397
+ border: 1px solid var(--border-color);
398
+ }
399
+
400
+ .generation-form {
401
+ display: flex;
402
+ flex-direction: column;
403
+ gap: 1.5rem;
404
+ }
405
+
406
+ /* 表单组 */
407
+ .form-group {
408
+ display: flex;
409
+ flex-direction: column;
410
+ gap: 0.5rem;
411
+ }
412
+
413
+ .form-label {
414
+ font-weight: 600;
415
+ color: var(--text-primary);
416
+ display: flex;
417
+ align-items: center;
418
+ gap: 0.5rem;
419
+ }
420
+
421
+ .form-label i {
422
+ color: var(--accent-primary);
423
+ }
424
+
425
+ /* 输入框 */
426
+ .form-input,
427
+ .form-textarea,
428
+ .form-select {
429
+ padding: 0.75rem 1rem;
430
+ border: 2px solid var(--border-color);
431
+ border-radius: 0.5rem;
432
+ background: var(--bg-secondary);
433
+ color: var(--text-primary);
434
+ font-size: 1rem;
435
+ transition: all 0.3s ease;
436
+ }
437
+
438
+ .form-input:focus,
439
+ .form-textarea:focus,
440
+ .form-select:focus {
441
+ outline: none;
442
+ border-color: var(--accent-primary);
443
+ box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
444
+ }
445
+
446
+ .form-textarea {
447
+ min-height: 100px;
448
+ resize: vertical;
449
+ }
450
+
451
+ /* 图片上传区域 */
452
+ .image-upload-area {
453
+ position: relative;
454
+ border: 2px dashed var(--border-color);
455
+ border-radius: 0.75rem;
456
+ padding: 2rem;
457
+ text-align: center;
458
+ cursor: pointer;
459
+ transition: all 0.3s ease;
460
+ background: var(--bg-secondary);
461
+ }
462
+
463
+ .image-upload-area:hover {
464
+ border-color: var(--accent-primary);
465
+ background: var(--bg-tertiary);
466
+ }
467
+
468
+ .image-upload-area.dragover {
469
+ border-color: var(--accent-primary);
470
+ background: var(--accent-primary);
471
+ background-opacity: 0.1;
472
+ }
473
+
474
+ .upload-placeholder i {
475
+ font-size: 3rem;
476
+ color: var(--accent-primary);
477
+ margin-bottom: 1rem;
478
+ }
479
+
480
+ .upload-placeholder p {
481
+ color: var(--text-secondary);
482
+ margin: 0.5rem 0;
483
+ }
484
+
485
+ .upload-hint {
486
+ font-size: 0.875rem;
487
+ color: var(--text-muted);
488
+ }
489
+
490
+ .image-preview {
491
+ position: relative;
492
+ display: inline-block;
493
+ }
494
+
495
+ .image-preview img {
496
+ max-width: 300px;
497
+ max-height: 200px;
498
+ border-radius: 0.5rem;
499
+ box-shadow: var(--shadow-md);
500
+ }
501
+
502
+ .remove-image {
503
+ position: absolute;
504
+ top: -0.5rem;
505
+ right: -0.5rem;
506
+ background: var(--error-color);
507
+ color: white;
508
+ border: none;
509
+ border-radius: 50%;
510
+ width: 2rem;
511
+ height: 2rem;
512
+ cursor: pointer;
513
+ display: flex;
514
+ align-items: center;
515
+ justify-content: center;
516
+ transition: all 0.3s ease;
517
+ }
518
+
519
+ .remove-image:hover {
520
+ transform: scale(1.1);
521
+ }
522
+
523
+ /* 范围滑块 */
524
+ .form-range {
525
+ width: 100%;
526
+ height: 6px;
527
+ border-radius: 3px;
528
+ background: var(--bg-tertiary);
529
+ outline: none;
530
+ -webkit-appearance: none;
531
+ }
532
+
533
+ .form-range::-webkit-slider-thumb {
534
+ -webkit-appearance: none;
535
+ appearance: none;
536
+ width: 20px;
537
+ height: 20px;
538
+ border-radius: 50%;
539
+ background: var(--accent-primary);
540
+ cursor: pointer;
541
+ box-shadow: var(--shadow-md);
542
+ }
543
+
544
+ .form-range::-moz-range-thumb {
545
+ width: 20px;
546
+ height: 20px;
547
+ border-radius: 50%;
548
+ background: var(--accent-primary);
549
+ cursor: pointer;
550
+ border: none;
551
+ box-shadow: var(--shadow-md);
552
+ }
553
+
554
+ .range-value {
555
+ color: var(--accent-primary);
556
+ font-weight: 600;
557
+ margin-left: 0.5rem;
558
+ }
559
+
560
+ /* 高级设置 */
561
+ .advanced-settings {
562
+ border: 1px solid var(--border-color);
563
+ border-radius: 0.75rem;
564
+ overflow: hidden;
565
+ }
566
+
567
+ .advanced-settings summary {
568
+ padding: 1rem 1.5rem;
569
+ background: var(--bg-tertiary);
570
+ cursor: pointer;
571
+ font-weight: 600;
572
+ display: flex;
573
+ align-items: center;
574
+ gap: 0.5rem;
575
+ transition: background-color 0.3s ease;
576
+ }
577
+
578
+ .advanced-settings summary:hover {
579
+ background: var(--border-color);
580
+ }
581
+
582
+ .advanced-settings[open] summary {
583
+ border-bottom: 1px solid var(--border-color);
584
+ }
585
+
586
+ .settings-grid {
587
+ padding: 1.5rem;
588
+ display: grid;
589
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
590
+ gap: 1.5rem;
591
+ }
592
+
593
+ /* 提交按钮 */
594
+ .submit-button {
595
+ background: var(--gradient-primary);
596
+ color: white;
597
+ border: none;
598
+ padding: 1rem 2rem;
599
+ border-radius: 0.75rem;
600
+ font-size: 1.1rem;
601
+ font-weight: 600;
602
+ cursor: pointer;
603
+ transition: all 0.3s ease;
604
+ display: flex;
605
+ align-items: center;
606
+ justify-content: center;
607
+ gap: 0.75rem;
608
+ box-shadow: var(--shadow-md);
609
+ }
610
+
611
+ .submit-button:hover {
612
+ transform: translateY(-2px);
613
+ box-shadow: var(--shadow-lg);
614
+ }
615
+
616
+ .submit-button:disabled {
617
+ opacity: 0.6;
618
+ cursor: not-allowed;
619
+ transform: none;
620
+ }
621
+
622
+ /* 结果容器 */
623
+
624
+ /* 生成队列(生成栏) */
625
+ .generation-queue {
626
+ background: var(--bg-primary);
627
+ border: 1px solid var(--border-color);
628
+ border-radius: 1rem;
629
+ padding: 1.5rem;
630
+ box-shadow: var(--shadow-lg);
631
+ margin-bottom: 1.5rem;
632
+ }
633
+
634
+ .queue-header {
635
+ display: flex;
636
+ justify-content: space-between;
637
+ align-items: center;
638
+ margin-bottom: 1rem;
639
+ padding-bottom: 1rem;
640
+ border-bottom: 1px solid var(--border-color);
641
+ }
642
+
643
+ .queue-header h3 {
644
+ display: flex;
645
+ align-items: center;
646
+ gap: 0.5rem;
647
+ color: var(--text-primary);
648
+ margin: 0;
649
+ }
650
+
651
+ .queue-count {
652
+ color: var(--text-muted);
653
+ font-size: 0.875rem;
654
+ }
655
+
656
+ .queue-list {
657
+ display: flex;
658
+ flex-direction: column;
659
+ gap: 0.75rem;
660
+ min-height: 80px;
661
+ }
662
+
663
+ .empty-queue {
664
+ display: flex;
665
+ flex-direction: column;
666
+ align-items: center;
667
+ justify-content: center;
668
+ color: var(--text-secondary);
669
+ gap: 0.75rem;
670
+ padding: 1rem;
671
+ }
672
+
673
+ .queue-item {
674
+ display: grid;
675
+ grid-template-columns: 1fr auto;
676
+ align-items: center;
677
+ gap: 0.75rem;
678
+ padding: 0.75rem 1rem;
679
+ border: 1px solid var(--border-color);
680
+ border-radius: 0.75rem;
681
+ background: var(--bg-secondary);
682
+ }
683
+
684
+ .queue-item .info {
685
+ display: flex;
686
+ flex-direction: column;
687
+ gap: 0.25rem;
688
+ }
689
+
690
+ .queue-item .title {
691
+ font-weight: 600;
692
+ color: var(--text-primary);
693
+ font-size: 0.9rem;
694
+ }
695
+
696
+ .queue-item .prompt {
697
+ color: var(--text-secondary);
698
+ font-size: 0.8rem;
699
+ display: -webkit-box;
700
+ -webkit-line-clamp: 2;
701
+ -webkit-box-orient: vertical;
702
+ overflow: hidden;
703
+ }
704
+
705
+ .queue-item .meta {
706
+ color: var(--text-muted);
707
+ font-size: 0.75rem;
708
+ }
709
+
710
+ .queue-progress {
711
+ min-width: 220px;
712
+ }
713
+
714
+ .queue-progress .progress-bar {
715
+ width: 100%;
716
+ height: 6px;
717
+ background: var(--bg-tertiary);
718
+ border-radius: 3px;
719
+ overflow: hidden;
720
+ }
721
+
722
+ .queue-progress .progress-fill {
723
+ height: 100%;
724
+ background: var(--gradient-primary);
725
+ width: 0%;
726
+ transition: width 0.3s ease;
727
+ }
728
+
729
+ .queue-status {
730
+ font-size: 0.75rem;
731
+ padding: 0.25rem 0.5rem;
732
+ border-radius: 0.25rem;
733
+ text-align: center;
734
+ min-width: 72px;
735
+ }
736
+
737
+ .queue-status.pending {
738
+ background: var(--bg-tertiary);
739
+ color: var(--text-secondary);
740
+ }
741
+
742
+ .queue-status.running {
743
+ background: var(--accent-primary);
744
+ color: white;
745
+ }
746
+
747
+ .queue-status.done {
748
+ background: var(--accent-secondary);
749
+ color: white;
750
+ }
751
+
752
+ .queue-status.error {
753
+ background: var(--error-color);
754
+ color: white;
755
+ }
756
+
757
+ /* 移动端优化:生成队列 */
758
+ @media (max-width: 768px) {
759
+ .generation-queue {
760
+ padding: 1rem;
761
+ }
762
+ .queue-item {
763
+ grid-template-columns: 1fr;
764
+ }
765
+ .queue-progress {
766
+ min-width: 100%;
767
+ }
768
+ }
769
+ .result-container {
770
+ background: var(--bg-primary);
771
+ border-radius: 1rem;
772
+ padding: 2rem;
773
+ box-shadow: var(--shadow-lg);
774
+ border: 1px solid var(--border-color);
775
+ margin-top: 2rem;
776
+ }
777
+
778
+ .result-header {
779
+ display: flex;
780
+ justify-content: space-between;
781
+ align-items: center;
782
+ margin-bottom: 1.5rem;
783
+ padding-bottom: 1rem;
784
+ border-bottom: 1px solid var(--border-color);
785
+ }
786
+
787
+ .result-header h3 {
788
+ display: flex;
789
+ align-items: center;
790
+ gap: 0.5rem;
791
+ color: var(--text-primary);
792
+ }
793
+
794
+ .clear-button {
795
+ background: var(--error-color);
796
+ color: white;
797
+ border: none;
798
+ border-radius: 0.5rem;
799
+ padding: 0.5rem 1rem;
800
+ cursor: pointer;
801
+ transition: all 0.3s ease;
802
+ display: flex;
803
+ align-items: center;
804
+ gap: 0.5rem;
805
+ }
806
+
807
+ .clear-button:hover {
808
+ background: #dc2626;
809
+ }
810
+
811
+ .result-video {
812
+ width: 100%;
813
+ max-width: 800px;
814
+ border-radius: 0.75rem;
815
+ box-shadow: var(--shadow-md);
816
+ margin-bottom: 1rem;
817
+ }
818
+
819
+ .result-actions {
820
+ display: flex;
821
+ gap: 1rem;
822
+ justify-content: center;
823
+ }
824
+
825
+ .download-button {
826
+ background: var(--accent-primary);
827
+ color: white;
828
+ text-decoration: none;
829
+ padding: 0.75rem 1.5rem;
830
+ border-radius: 0.5rem;
831
+ font-weight: 600;
832
+ transition: all 0.3s ease;
833
+ display: flex;
834
+ align-items: center;
835
+ gap: 0.5rem;
836
+ }
837
+
838
+ .download-button:hover {
839
+ background: var(--accent-hover);
840
+ transform: translateY(-1px);
841
+ }
842
+
843
+ /* 加载状态 */
844
+ .loading-container {
845
+ position: fixed;
846
+ top: 0;
847
+ left: 0;
848
+ right: 0;
849
+ bottom: 0;
850
+ background: rgba(0, 0, 0, 0.8);
851
+ display: flex;
852
+ align-items: center;
853
+ justify-content: center;
854
+ z-index: 1000;
855
+ backdrop-filter: blur(4px);
856
+ }
857
+
858
+ .loading-content {
859
+ background: var(--bg-primary);
860
+ border-radius: 1rem;
861
+ padding: 3rem;
862
+ text-align: center;
863
+ max-width: 400px;
864
+ box-shadow: var(--shadow-lg);
865
+ }
866
+
867
+ .loading-spinner {
868
+ width: 60px;
869
+ height: 60px;
870
+ border: 4px solid var(--border-color);
871
+ border-top: 4px solid var(--accent-primary);
872
+ border-radius: 50%;
873
+ animation: spin 1s linear infinite;
874
+ margin: 0 auto 1.5rem;
875
+ }
876
+
877
+ @keyframes spin {
878
+ 0% { transform: rotate(0deg); }
879
+ 100% { transform: rotate(360deg); }
880
+ }
881
+
882
+ .loading-progress {
883
+ margin-top: 1.5rem;
884
+ }
885
+
886
+ .progress-bar {
887
+ width: 100%;
888
+ height: 8px;
889
+ background: var(--bg-tertiary);
890
+ border-radius: 4px;
891
+ overflow: hidden;
892
+ }
893
+
894
+ .progress-fill {
895
+ height: 100%;
896
+ background: var(--gradient-primary);
897
+ width: 0%;
898
+ transition: width 0.3s ease;
899
+ animation: pulse 2s infinite;
900
+ }
901
+
902
+ @keyframes pulse {
903
+ 0%, 100% { opacity: 1; }
904
+ 50% { opacity: 0.7; }
905
+ }
906
+
907
+ /* 视频库样式 */
908
+ .video-library-header {
909
+ display: flex;
910
+ justify-content: space-between;
911
+ align-items: center;
912
+ margin-bottom: 2rem;
913
+ padding-bottom: 1rem;
914
+ border-bottom: 1px solid var(--border-color);
915
+ }
916
+
917
+ .video-library-header h3 {
918
+ display: flex;
919
+ align-items: center;
920
+ gap: 0.5rem;
921
+ color: var(--text-primary);
922
+ margin: 0;
923
+ }
924
+
925
+ .library-controls {
926
+ display: flex;
927
+ align-items: center;
928
+ gap: 1rem;
929
+ }
930
+
931
+ .refresh-button {
932
+ background: var(--accent-primary);
933
+ color: white;
934
+ border: none;
935
+ border-radius: 0.5rem;
936
+ padding: 0.5rem 1rem;
937
+ cursor: pointer;
938
+ transition: all 0.3s ease;
939
+ display: flex;
940
+ align-items: center;
941
+ gap: 0.5rem;
942
+ font-size: 0.875rem;
943
+ }
944
+
945
+ .refresh-button:hover {
946
+ background: var(--accent-hover);
947
+ transform: translateY(-1px);
948
+ }
949
+
950
+ .video-count {
951
+ color: var(--text-secondary);
952
+ font-size: 0.875rem;
953
+ }
954
+
955
+ .video-library-content {
956
+ min-height: 300px;
957
+ }
958
+
959
+ .loading-library {
960
+ display: flex;
961
+ flex-direction: column;
962
+ align-items: center;
963
+ justify-content: center;
964
+ height: 200px;
965
+ color: var(--text-secondary);
966
+ gap: 1rem;
967
+ }
968
+
969
+ .video-grid {
970
+ display: grid;
971
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
972
+ gap: 1.5rem;
973
+ margin-top: 1rem;
974
+ }
975
+
976
+ .video-item {
977
+ background: var(--bg-secondary);
978
+ border-radius: 0.75rem;
979
+ padding: 1rem;
980
+ border: 1px solid var(--border-color);
981
+ transition: all 0.3s ease;
982
+ }
983
+
984
+ .video-item:hover {
985
+ transform: translateY(-2px);
986
+ box-shadow: var(--shadow-md);
987
+ }
988
+
989
+ .video-preview {
990
+ width: 100%;
991
+ border-radius: 0.5rem;
992
+ margin-bottom: 1rem;
993
+ background: #000;
994
+ }
995
+
996
+ .video-info {
997
+ display: flex;
998
+ flex-direction: column;
999
+ gap: 0.5rem;
1000
+ }
1001
+
1002
+ .video-filename {
1003
+ font-weight: 600;
1004
+ color: var(--text-primary);
1005
+ font-size: 0.875rem;
1006
+ word-break: break-all;
1007
+ }
1008
+
1009
+ .video-meta {
1010
+ display: flex;
1011
+ justify-content: space-between;
1012
+ align-items: center;
1013
+ font-size: 0.75rem;
1014
+ color: var(--text-muted);
1015
+ }
1016
+
1017
+ .video-size {
1018
+ color: var(--accent-primary);
1019
+ }
1020
+
1021
+ .video-actions {
1022
+ display: flex;
1023
+ gap: 0.5rem;
1024
+ margin-top: 0.5rem;
1025
+ }
1026
+
1027
+ .video-action-btn {
1028
+ flex: 1;
1029
+ padding: 0.5rem;
1030
+ border: none;
1031
+ border-radius: 0.375rem;
1032
+ cursor: pointer;
1033
+ font-size: 0.75rem;
1034
+ transition: all 0.3s ease;
1035
+ display: flex;
1036
+ align-items: center;
1037
+ justify-content: center;
1038
+ gap: 0.25rem;
1039
+ }
1040
+
1041
+ .download-video-btn {
1042
+ background: var(--accent-primary);
1043
+ color: white;
1044
+ }
1045
+
1046
+ .download-video-btn:hover {
1047
+ background: var(--accent-hover);
1048
+ }
1049
+
1050
+ .play-video-btn {
1051
+ background: var(--accent-primary);
1052
+ color: white;
1053
+ }
1054
+
1055
+ .play-video-btn:hover {
1056
+ background: var(--accent-hover);
1057
+ }
1058
+
1059
+ .empty-library {
1060
+ display: flex;
1061
+ flex-direction: column;
1062
+ align-items: center;
1063
+ justify-content: center;
1064
+ height: 200px;
1065
+ color: var(--text-secondary);
1066
+ gap: 1rem;
1067
+ }
1068
+
1069
+ .empty-library i {
1070
+ font-size: 3rem;
1071
+ color: var(--text-muted);
1072
+ }
1073
+
1074
+ /* 底部 */
1075
+ .footer {
1076
+ background: var(--bg-primary);
1077
+ border-top: 1px solid var(--border-color);
1078
+ padding: 2rem;
1079
+ text-align: center;
1080
+ color: var(--text-muted);
1081
+ }
1082
+
1083
+ /* 响应式设计 */
1084
+ @media (max-width: 768px) {
1085
+ .main {
1086
+ padding: 1rem;
1087
+ }
1088
+
1089
+ .header-content {
1090
+ padding: 1rem;
1091
+ }
1092
+
1093
+ .title {
1094
+ font-size: 1.5rem;
1095
+ }
1096
+
1097
+ .form-container {
1098
+ padding: 1.5rem;
1099
+ }
1100
+
1101
+ .settings-grid {
1102
+ grid-template-columns: 1fr;
1103
+ }
1104
+
1105
+ .tabs {
1106
+ flex-direction: column;
1107
+ }
1108
+
1109
+ .loading-content {
1110
+ margin: 1rem;
1111
+ padding: 2rem;
1112
+ }
1113
+
1114
+ .modal-content {
1115
+ width: 95%;
1116
+ }
1117
+
1118
+ .modal-actions {
1119
+ flex-direction: column;
1120
+ }
1121
+ }
1122
+
1123
+ @media (max-width: 480px) {
1124
+ .header-content {
1125
+ flex-direction: column;
1126
+ gap: 1rem;
1127
+ }
1128
+
1129
+ .title {
1130
+ font-size: 1.25rem;
1131
+ }
1132
+
1133
+ .form-container {
1134
+ padding: 1rem;
1135
+ }
1136
+ }
server.js ADDED
@@ -0,0 +1,598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const multer = require('multer');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const https = require('https');
7
+ const http = require('http');
8
+ require('dotenv').config();
9
+
10
+ const app = express();
11
+ const PORT = process.env.PORT || 7860;
12
+
13
+ // 中间件配置
14
+ app.use(cors());
15
+ app.use(express.json({ limit: '50mb' }));
16
+ app.use(express.urlencoded({ extended: true, limit: '50mb' }));
17
+ app.use(express.static(path.join(__dirname, 'public')));
18
+
19
+ // API key 存储文件路径
20
+ const API_KEY_FILE = path.join(__dirname, 'stored_api_key.json');
21
+
22
+ // 视频存储目录
23
+ const VIDEO_DIR = path.join(__dirname, 'video');
24
+
25
+ // 文件上传配置
26
+ const storage = multer.diskStorage({
27
+ destination: (req, file, cb) => {
28
+ const uploadDir = path.join(__dirname, 'uploads');
29
+ if (!fs.existsSync(uploadDir)) {
30
+ fs.mkdirSync(uploadDir, { recursive: true });
31
+ }
32
+ cb(null, uploadDir);
33
+ },
34
+ filename: (req, file, cb) => {
35
+ cb(null, Date.now() + '-' + file.originalname);
36
+ }
37
+ });
38
+
39
+ const upload = multer({
40
+ storage: storage,
41
+ limits: { fileSize: 50 * 1024 * 1024 }
42
+ });
43
+
44
+ // 读取存储的 API key
45
+ function getStoredApiKey() {
46
+ try {
47
+ if (fs.existsSync(API_KEY_FILE)) {
48
+ const data = fs.readFileSync(API_KEY_FILE, 'utf8');
49
+ const parsed = JSON.parse(data);
50
+ return parsed.apiKey || null;
51
+ }
52
+ } catch (error) {
53
+ console.log('读取存储的 API key 失败:', error.message);
54
+ }
55
+ return null;
56
+ }
57
+
58
+ // 保存 API key
59
+ function saveApiKey(apiKey) {
60
+ try {
61
+ const data = { apiKey, savedAt: new Date().toISOString() };
62
+ fs.writeFileSync(API_KEY_FILE, JSON.stringify(data, null, 2));
63
+ return true;
64
+ } catch (error) {
65
+ console.error('保存 API key 失败:', error);
66
+ return false;
67
+ }
68
+ }
69
+
70
+ // 获取有效的 API key
71
+ function getValidApiKey(userApiKey = null) {
72
+ // 优先级:用户提供的 > 环境变量 > 存储的
73
+ return userApiKey || process.env.FAL_KEY || getStoredApiKey();
74
+ }
75
+
76
+ // 下载并保存视频文件
77
+ function downloadVideo(videoUrl, filename) {
78
+ return new Promise((resolve, reject) => {
79
+ // 确保视频目录存在
80
+ if (!fs.existsSync(VIDEO_DIR)) {
81
+ fs.mkdirSync(VIDEO_DIR, { recursive: true });
82
+ }
83
+
84
+ const filepath = path.join(VIDEO_DIR, filename);
85
+ const file = fs.createWriteStream(filepath);
86
+
87
+ const protocol = videoUrl.startsWith('https:') ? https : http;
88
+
89
+ protocol.get(videoUrl, (response) => {
90
+ if (response.statusCode !== 200) {
91
+ reject(new Error(`下载失败,状态码: ${response.statusCode}`));
92
+ return;
93
+ }
94
+
95
+ response.pipe(file);
96
+
97
+ file.on('finish', () => {
98
+ file.close();
99
+ console.log(`视频已保存到: ${filepath}`);
100
+ resolve(filepath);
101
+ });
102
+
103
+ file.on('error', (err) => {
104
+ fs.unlink(filepath, () => {}); // 删除不完整的文件
105
+ reject(err);
106
+ });
107
+ }).on('error', (err) => {
108
+ reject(err);
109
+ });
110
+ });
111
+ }
112
+
113
+ // 生成唯一的文件名
114
+ function generateVideoFilename(type = 'video') {
115
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
116
+ return `${type}-${timestamp}.mp4`;
117
+ }
118
+
119
+ // FAL AI 客户端初始化
120
+ let fal;
121
+ try {
122
+ fal = require('@fal-ai/client');
123
+ // 如果是对象形式的导入,尝试获取 fal 属性
124
+ if (fal && typeof fal === 'object' && fal.fal) {
125
+ fal = fal.fal;
126
+ }
127
+ } catch (error) {
128
+ console.error('FAL AI 客户端初始化失败:', error);
129
+ }
130
+
131
+ // 路由
132
+ app.get('/', (req, res) => {
133
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
134
+ });
135
+
136
+ // API key 管理接口
137
+ app.post('/api/save-key', (req, res) => {
138
+ const { apiKey } = req.body;
139
+
140
+ if (!apiKey || !apiKey.trim()) {
141
+ return res.status(400).json({ error: 'API key 不能为空' });
142
+ }
143
+
144
+ if (saveApiKey(apiKey.trim())) {
145
+ res.json({ success: true, message: 'API key 保存成功' });
146
+ } else {
147
+ res.status(500).json({ error: 'API key 保存失败' });
148
+ }
149
+ });
150
+
151
+ app.get('/api/check-key', (req, res) => {
152
+ const storedKey = getStoredApiKey();
153
+ const hasEnvKey = !!process.env.FAL_KEY;
154
+
155
+ res.json({
156
+ hasStoredKey: !!storedKey,
157
+ hasEnvKey: hasEnvKey,
158
+ keySource: hasEnvKey ? 'environment' : (storedKey ? 'stored' : 'none')
159
+ });
160
+ });
161
+
162
+ // 图片到视频生成 API(支持模型切换:WAN v2.2-a14b 与 Bytedance Seedance 1.0 Pro Fast)
163
+ app.post('/api/image-to-video', upload.single('image'), async (req, res) => {
164
+ try {
165
+ const userApiKey = req.body.userApiKey;
166
+ const apiKey = getValidApiKey(userApiKey);
167
+
168
+ if (!apiKey) {
169
+ return res.status(400).json({ error: '请提供 FAL AI API 密钥' });
170
+ }
171
+
172
+ const prompt = req.body.prompt;
173
+ if (!prompt) {
174
+ return res.status(400).json({ error: '请提供文本提示' });
175
+ }
176
+
177
+ // 解析图片输入(文件或 URL)
178
+ let image_url;
179
+ if (req.file) {
180
+ const file = fs.readFileSync(req.file.path);
181
+ const blob = new Blob([file], { type: req.file.mimetype });
182
+ image_url = await fal.storage.upload(blob);
183
+ } else if (req.body.image_url) {
184
+ image_url = req.body.image_url;
185
+ } else {
186
+ return res.status(400).json({ error: '请提供图片文件或图片URL' });
187
+ }
188
+
189
+ // 模型选择:默认 WAN,支持 seedance-pro-fast
190
+ const model = req.body.model || 'wan-v2.2-a14b';
191
+
192
+ // 设置临时环境变量
193
+ const originalFalKey = process.env.FAL_KEY;
194
+ process.env.FAL_KEY = apiKey;
195
+
196
+ let result;
197
+
198
+ if (model === 'seedance-pro-fast') {
199
+ // Bytedance Seedance 1.0 Pro Fast 入参映射
200
+ const {
201
+ aspect_ratio = 'auto',
202
+ resolution = '1080p',
203
+ duration = '5',
204
+ camera_fixed = false,
205
+ seed = -1,
206
+ enable_safety_checker = true,
207
+ } = req.body;
208
+
209
+ result = await fal.subscribe("fal-ai/bytedance/seedance/v1/pro/fast/image-to-video", {
210
+ input: {
211
+ prompt,
212
+ image_url,
213
+ aspect_ratio,
214
+ resolution,
215
+ duration,
216
+ camera_fixed: typeof camera_fixed === 'string' ? camera_fixed === 'true' : !!camera_fixed,
217
+ seed: parseInt(seed, 10),
218
+ enable_safety_checker: typeof enable_safety_checker === 'string' ? enable_safety_checker === 'true' : !!enable_safety_checker,
219
+ },
220
+ logs: true,
221
+ onQueueUpdate: (update) => {
222
+ if (update.status === "IN_PROGRESS") {
223
+ console.log('生成进度(Seedance I2V):', update.logs?.map(log => log.message).join('\n'));
224
+ }
225
+ },
226
+ });
227
+ } else {
228
+ // WAN v2.2-a14b 入参映射(保持原逻辑)
229
+ const {
230
+ negative_prompt = "",
231
+ num_frames = 81,
232
+ frames_per_second = 16,
233
+ resolution = "720p",
234
+ aspect_ratio = "auto",
235
+ video_quality = "high",
236
+ enable_safety_checker = "true"
237
+ } = req.body;
238
+
239
+ result = await fal.subscribe("fal-ai/wan/v2.2-a14b/image-to-video", {
240
+ input: {
241
+ image_url,
242
+ prompt,
243
+ negative_prompt,
244
+ num_frames: parseInt(num_frames),
245
+ frames_per_second: parseInt(frames_per_second),
246
+ resolution,
247
+ aspect_ratio,
248
+ video_quality,
249
+ num_inference_steps: 27,
250
+ enable_safety_checker: enable_safety_checker === "true",
251
+ acceleration: "regular",
252
+ guidance_scale: 3.5,
253
+ shift: 5
254
+ },
255
+ logs: true,
256
+ onQueueUpdate: (update) => {
257
+ if (update.status === "IN_PROGRESS") {
258
+ console.log('生成进度(WAN I2V):', update.logs?.map(log => log.message).join('\n'));
259
+ }
260
+ },
261
+ });
262
+ }
263
+
264
+ // 恢复原始环境变量
265
+ if (originalFalKey) {
266
+ process.env.FAL_KEY = originalFalKey;
267
+ }
268
+
269
+ // 下载并保存视频到本地
270
+ let localVideoPath = null;
271
+ if (result.data && result.data.video && result.data.video.url) {
272
+ try {
273
+ const filename = generateVideoFilename('image-to-video');
274
+ localVideoPath = await downloadVideo(result.data.video.url, filename);
275
+ console.log(`图片转视频完成,已保存到: ${localVideoPath}`);
276
+ } catch (downloadError) {
277
+ console.error('视频下载失败:', downloadError);
278
+ // 不影响主要功能,继续返回结果
279
+ }
280
+ }
281
+
282
+ if (req.file && fs.existsSync(req.file.path)) {
283
+ fs.unlinkSync(req.file.path);
284
+ }
285
+
286
+ res.json({
287
+ success: true,
288
+ data: {
289
+ ...result.data,
290
+ localPath: localVideoPath ? path.relative(__dirname, localVideoPath) : null
291
+ },
292
+ requestId: result.requestId
293
+ });
294
+
295
+ } catch (error) {
296
+ console.error('视频生成错误:', error);
297
+
298
+ if (req.file && fs.existsSync(req.file.path)) {
299
+ fs.unlinkSync(req.file.path);
300
+ }
301
+
302
+ res.status(500).json({
303
+ success: false,
304
+ error: error.message || '视频生成失败'
305
+ });
306
+ }
307
+ });
308
+
309
+ // 文本到视频生成 API(支持模型切换:WAN v2.2-a14b 与 Bytedance Seedance 1.0 Pro Fast)
310
+ app.post('/api/text-to-video', async (req, res) => {
311
+ try {
312
+ const userApiKey = req.body.userApiKey;
313
+ const apiKey = getValidApiKey(userApiKey);
314
+
315
+ if (!apiKey) {
316
+ return res.status(400).json({ error: '请提供 FAL AI API 密钥' });
317
+ }
318
+
319
+ const prompt = req.body.prompt;
320
+ if (!prompt) {
321
+ return res.status(400).json({ error: '请提供文本提示' });
322
+ }
323
+
324
+ // 模型选择:默认 WAN,支持 seedance-pro-fast
325
+ const model = req.body.model || 'wan-v2.2-a14b';
326
+
327
+ // 设置临时环境变量
328
+ const originalFalKey = process.env.FAL_KEY;
329
+ process.env.FAL_KEY = apiKey;
330
+
331
+ let result;
332
+
333
+ if (model === 'seedance-pro-fast') {
334
+ // Bytedance Seedance 1.0 Pro Fast 入参映射
335
+ const {
336
+ aspect_ratio = '16:9',
337
+ resolution = '1080p',
338
+ duration = '5',
339
+ camera_fixed = false,
340
+ seed = -1,
341
+ enable_safety_checker = true,
342
+ } = req.body;
343
+
344
+ result = await fal.subscribe("fal-ai/bytedance/seedance/v1/pro/fast/text-to-video", {
345
+ input: {
346
+ prompt,
347
+ aspect_ratio,
348
+ resolution,
349
+ duration,
350
+ camera_fixed: !!camera_fixed,
351
+ seed: parseInt(seed, 10),
352
+ enable_safety_checker: !!enable_safety_checker,
353
+ },
354
+ logs: true,
355
+ onQueueUpdate: (update) => {
356
+ if (update.status === "IN_PROGRESS") {
357
+ console.log('生成进度(Seedance):', update.logs?.map(log => log.message).join('\n'));
358
+ }
359
+ },
360
+ });
361
+ } else {
362
+ // WAN v2.2-a14b 入参映射(保持原逻辑)
363
+ const {
364
+ negative_prompt = "",
365
+ num_frames = 81,
366
+ frames_per_second = 16,
367
+ resolution = "720p",
368
+ aspect_ratio = "16:9",
369
+ video_quality = "high",
370
+ enable_safety_checker = true
371
+ } = req.body;
372
+
373
+ result = await fal.subscribe("fal-ai/wan/v2.2-a14b/text-to-video", {
374
+ input: {
375
+ prompt,
376
+ negative_prompt,
377
+ num_frames: parseInt(num_frames),
378
+ frames_per_second: parseInt(frames_per_second),
379
+ resolution,
380
+ aspect_ratio,
381
+ video_quality,
382
+ num_inference_steps: 27,
383
+ enable_safety_checker: enable_safety_checker,
384
+ acceleration: "regular",
385
+ guidance_scale: 3.5,
386
+ shift: 5
387
+ },
388
+ logs: true,
389
+ onQueueUpdate: (update) => {
390
+ if (update.status === "IN_PROGRESS") {
391
+ console.log('生成进度(WAN):', update.logs?.map(log => log.message).join('\n'));
392
+ }
393
+ },
394
+ });
395
+ }
396
+
397
+ // 恢复原始环境变量
398
+ if (originalFalKey) {
399
+ process.env.FAL_KEY = originalFalKey;
400
+ }
401
+
402
+ // 下载并保存视频到本地
403
+ let localVideoPath = null;
404
+ if (result.data && result.data.video && result.data.video.url) {
405
+ try {
406
+ const filename = generateVideoFilename('text-to-video');
407
+ localVideoPath = await downloadVideo(result.data.video.url, filename);
408
+ console.log(`文本转视频完成,已保存到: ${localVideoPath}`);
409
+ } catch (downloadError) {
410
+ console.error('视频下载失败:', downloadError);
411
+ // 不影响主要功能,继续返回结果
412
+ }
413
+ }
414
+
415
+ res.json({
416
+ success: true,
417
+ data: {
418
+ ...result.data,
419
+ localPath: localVideoPath ? path.relative(__dirname, localVideoPath) : null
420
+ },
421
+ requestId: result.requestId
422
+ });
423
+
424
+ } catch (error) {
425
+ console.error('视频生成错误:', error);
426
+ res.status(500).json({
427
+ success: false,
428
+ error: error.message || '视频生成失败'
429
+ });
430
+ }
431
+ });
432
+
433
+ // 获取本地视频列表
434
+ app.get('/api/videos', (req, res) => {
435
+ try {
436
+ if (!fs.existsSync(VIDEO_DIR)) {
437
+ return res.json({ videos: [] });
438
+ }
439
+
440
+ const files = fs.readdirSync(VIDEO_DIR)
441
+ .filter(file => file.endsWith('.mp4'))
442
+ .map(file => {
443
+ const filepath = path.join(VIDEO_DIR, file);
444
+ const stats = fs.statSync(filepath);
445
+ return {
446
+ filename: file,
447
+ path: path.join('video', file),
448
+ size: stats.size,
449
+ created: stats.birthtime,
450
+ modified: stats.mtime
451
+ };
452
+ })
453
+ .sort((a, b) => new Date(b.created) - new Date(a.created)); // 按创建时间倒序
454
+
455
+ res.json({ videos: files });
456
+ } catch (error) {
457
+ console.error('获取视频列表失败:', error);
458
+ res.status(500).json({ error: '获取视频列表失败' });
459
+ }
460
+ });
461
+
462
+ // 提供视频文件访问
463
+ app.use('/video', express.static(path.join(__dirname, 'video')));
464
+
465
+ // 测试 API 密钥连接(使用免费的健康检查端点)
466
+ app.post('/api/test-key', async (req, res) => {
467
+ try {
468
+ const { apiKey } = req.body;
469
+ const testKey = apiKey || getValidApiKey();
470
+
471
+ if (!testKey) {
472
+ return res.status(400).json({
473
+ error: 'API 密钥未提供',
474
+ success: false
475
+ });
476
+ }
477
+
478
+ // 使用 FAL AI 的免费健康检查端点测试连接
479
+ const https = require('https');
480
+
481
+ const testPromise = new Promise((resolve, reject) => {
482
+ // 使用 FAL AI 的状态检查端点(不消耗配额)
483
+ const options = {
484
+ hostname: 'fal.run',
485
+ port: 443,
486
+ path: '/status',
487
+ method: 'GET',
488
+ headers: {
489
+ 'Authorization': `Key ${testKey}`,
490
+ 'User-Agent': 'FAL-Video-Generator/1.0'
491
+ },
492
+ timeout: 5000
493
+ };
494
+
495
+ const req = https.request(options, (res) => {
496
+ let data = '';
497
+ res.on('data', (chunk) => data += chunk);
498
+ res.on('end', () => {
499
+ if (res.statusCode === 401) {
500
+ reject(new Error('Unauthorized - API 密钥无效'));
501
+ } else if (res.statusCode === 403) {
502
+ reject(new Error('Forbidden - API 密钥被禁用'));
503
+ } else if (res.statusCode === 429) {
504
+ reject(new Error('Rate Limited - 请求过于频繁'));
505
+ } else if (res.statusCode >= 200 && res.statusCode < 300) {
506
+ resolve({ statusCode: res.statusCode, data });
507
+ } else {
508
+ // 其他状态码,但至少证明密钥被识别了
509
+ resolve({ statusCode: res.statusCode, data });
510
+ }
511
+ });
512
+ });
513
+
514
+ req.on('error', (err) => {
515
+ if (err.code === 'ENOTFOUND') {
516
+ reject(new Error('Network - 无法连接到 FAL AI 服务器'));
517
+ } else if (err.code === 'ECONNREFUSED') {
518
+ reject(new Error('Connection - 连接被拒绝'));
519
+ } else {
520
+ reject(new Error(`Network - ${err.message}`));
521
+ }
522
+ });
523
+
524
+ req.on('timeout', () => {
525
+ req.destroy();
526
+ reject(new Error('Timeout - 连接超时'));
527
+ });
528
+
529
+ req.end();
530
+ });
531
+
532
+ try {
533
+ const result = await testPromise;
534
+
535
+ res.json({
536
+ success: true,
537
+ message: '✅ 连接成功!API 密钥有效',
538
+ keyFormat: testKey.substring(0, 8) + '...' + testKey.substring(testKey.length - 4),
539
+ statusCode: result.statusCode,
540
+ note: '🆓 使用免费健康检查端点,不消耗配额'
541
+ });
542
+
543
+ } catch (testError) {
544
+ let errorMessage = '❌ 连接测试失败';
545
+ let errorType = 'error';
546
+
547
+ if (testError.message.includes('Unauthorized')) {
548
+ errorMessage = '❌ API 密钥无效或已过期';
549
+ errorType = 'auth';
550
+ } else if (testError.message.includes('Forbidden')) {
551
+ errorMessage = '❌ API 密钥被禁用';
552
+ errorType = 'auth';
553
+ } else if (testError.message.includes('Rate Limited')) {
554
+ errorMessage = '⚠️ 请求过于频繁,请稍后重试';
555
+ errorType = 'rate';
556
+ } else if (testError.message.includes('Network')) {
557
+ errorMessage = '🌐 网络连接问题';
558
+ errorType = 'network';
559
+ } else if (testError.message.includes('Timeout')) {
560
+ errorMessage = '⏱️ 连接超时,请重试';
561
+ errorType = 'timeout';
562
+ }
563
+
564
+ res.status(errorType === 'auth' ? 401 : 500).json({
565
+ success: false,
566
+ error: errorMessage,
567
+ errorType: errorType,
568
+ details: testError.message
569
+ });
570
+ }
571
+
572
+ } catch (error) {
573
+ console.error('API 密钥连接测试失败:', error);
574
+ res.status(500).json({
575
+ success: false,
576
+ error: '测试过程中发生错误',
577
+ details: error.message
578
+ });
579
+ }
580
+ });
581
+
582
+ app.get('/api/health', (req, res) => {
583
+ const apiKey = getValidApiKey();
584
+ res.json({
585
+ status: 'healthy',
586
+ timestamp: new Date().toISOString(),
587
+ hasApiKey: !!apiKey,
588
+ keyPreview: apiKey ? apiKey.substring(0, 8) + '...' : null
589
+ });
590
+ });
591
+
592
+ app.listen(PORT, '0.0.0.0', () => {
593
+ console.log(`服务器运行在端口 ${PORT}`);
594
+ const apiKey = getValidApiKey();
595
+ console.log(`API Key 状态: ${apiKey ? '已配置' : '未配置'}`);
596
+ });
597
+
598
+ module.exports = app;