├── screen.png
├── screen2.png
├── requirements.txt
├── LICENSE
├── static
├── browse.js
├── script.js
└── style.css
├── templates
├── index.html
└── browse.html
├── README.md
└── app.py
/screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/handsomejustin/ai-video-script-generator/HEAD/screen.png
--------------------------------------------------------------------------------
/screen2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/handsomejustin/ai-video-script-generator/HEAD/screen2.png
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Flask==3.1.0
2 | flask_limiter==3.10.1
3 | PyMySQL==1.1.1
4 | requests==2.32.3
5 | python-dotenv==0.21.1
6 | Werkzeug==3.1.3
7 | Jinja2==3.1.5
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Justin Gu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/static/browse.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', () => {
2 | // 点击列表项加载详情
3 | document.querySelectorAll('.list-item').forEach(item => {
4 | item.addEventListener('click', async function() {
5 | // 移除所有激活状态
6 | document.querySelectorAll('.list-item').forEach(i => {
7 | i.style.background = 'rgba(240, 242, 255, 0.3)';
8 | i.querySelector('.item-title').style.color = 'var(--primary-color)';
9 | });
10 |
11 | // 设置当前激活状态
12 | this.style.background = 'var(--accent-color)';
13 | this.querySelector('.item-title').style.color = 'white';
14 |
15 | // 显示加载状态
16 | const detailPanel = document.querySelector('.detail-content');
17 | detailPanel.style.opacity = '0.5';
18 |
19 | try {
20 | const itemId = this.dataset.id;
21 | const response = await fetch(`/get_detail/${itemId}`);
22 | const data = await response.json();
23 |
24 | // 填充数据
25 | document.getElementById('detail-title').textContent = data.title;
26 | document.getElementById('detail-opening').textContent = data.opening;
27 | document.getElementById('detail-script').textContent = data.script;
28 | document.getElementById('detail-direction').textContent = data.direction;
29 | document.getElementById('detail-dialogue').textContent = data.dialogue;
30 |
31 | } catch (error) {
32 | alert('获取详情失败: ' + error.message);
33 | } finally {
34 | detailPanel.style.opacity = '1';
35 | }
36 | });
37 | });
38 |
39 | // 预加载第一项
40 | const firstItem = document.querySelector('.list-item');
41 | if(firstItem) firstItem.click();
42 | });
--------------------------------------------------------------------------------
/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 智能短视频策划系统
7 |
8 |
9 |
10 |
11 |
12 |
查看历史策划方案 →
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/templates/browse.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 金点子策划浏览
7 |
8 |
9 |
10 |
11 |
12 |
36 |
37 |
38 |
39 |
43 |
44 |
48 |
52 |
56 |
60 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 智能短视频策划系统 - AI Video Script Generator
2 |
3 | [](https://www.python.org/)
4 | [](https://flask.palletsprojects.com/)
5 | [](LICENSE)
6 |
7 | 一个基于ollama DeepSeek-R1的AI的智能短视频脚本生成系统,集成内容创作、结构化存储与方案浏览功能,助力高效视频内容生产。
8 |
9 | 
10 | 
11 |
12 | ## 🌟 核心功能
13 |
14 | - **智能生成**:集成DeepSeek大模型,自动生成标题/脚本/分镜
15 | - **结构化存储**:MySQL数据库持久化保存方案
16 | - **可视化浏览**:双栏式历史方案检视界面
17 | - **交互设计**:动态加载提示与实时反馈
18 | - **可扩展架构**:模块化设计便于功能扩展
19 |
20 | ## 🛠️ 技术栈
21 |
22 | | 组件 | 技术选型 |
23 | |---------------|--------------------------|
24 | | 后端框架 | Python Flask |
25 | | AI集成 | Ollama + DeepSeek-r1:32b |
26 | | 数据库 | MySQL 8.0+ |
27 | | 前端 | HTML5/CSS3/ES6 |
28 |
29 | ## 🚀 快速开始
30 |
31 | ### 环境要求
32 |
33 | - Python 3.11+
34 | - MySQL 8.0+
35 | - Ollama服务+DeepSeek R1 32B+(本地运行,强烈建议使用DeepSeek R1 32B以上版本)
36 | or
37 | - OpenAI兼容方式的API方式调用(可自行在代码中修改)
38 |
39 |
40 | ### 安装步骤
41 |
42 | 1. **克隆仓库**
43 | ```bash
44 | git clone https://github.com/handsomejustin/ai-video-script-generator.git
45 | cd ai-video-script-generator
46 | ```
47 |
48 | 2. **安装依赖**
49 | ```bash
50 | pip install -r requirements.txt
51 | ```
52 |
53 | 3. **配置ollama**
54 | ```
55 | C:\Users\abc>ollama list
56 | NAME ID SIZE MODIFIED
57 | openthinker:32b b3f4e577e166 19 GB 11 days ago
58 | deepseek-r1:32b 38056bbcbb2d 19 GB 5 weeks ago
59 | ```
60 | 确认你已正确安装了ollama,同时已pull了deepseek-r1:32b(显卡需要nVdia RTX4090 24G)
61 | 如尚未安装,可以使用命令:
62 | ```
63 | C:\Users\abc>ollama pull deepseek-r1:32b
64 | ```
65 |
66 | 4. **数据库配置**
67 | ```sql
68 | CREATE DATABASE cehua;
69 | USE cehua;
70 |
71 | CREATE TABLE todo_list (
72 | id INT AUTO_INCREMENT PRIMARY KEY,
73 | title VARCHAR(255) NOT NULL,
74 | opening TEXT NOT NULL,
75 | script TEXT NOT NULL,
76 | direction TEXT NOT NULL,
77 | dialogue TEXT NOT NULL,
78 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
79 | );
80 | ```
81 |
82 | 5. **配置环境变量**
83 | 在app.py中修改:
84 | ```
85 | # 数据库配置
86 | db_config = {
87 | 'host': 'localhost',
88 | 'user': 'root',
89 | 'password': 'yourpassword',
90 | 'database': 'cehua',
91 | 'port': 3306,
92 | 'charset': 'utf8mb4'
93 | }
94 | ```
95 |
96 | 6. **启动服务**
97 | ```bash
98 | python app.py
99 | ```
100 |
101 | 7. **访问系统**
102 | 打开浏览器访问:`http://localhost:5000`
103 |
104 | ## 🖥️ 使用指南
105 |
106 | ### 内容生成流程
107 | 1. 在首页输入需求描述和金点子
108 | 2. 点击「生成策划方案」获取AI原始响应
109 | 3. 逐个点击各字段的「提取」按钮结构化数据
110 | 4. 确认无误后保存到数据库
111 |
112 | ### 方案浏览模式
113 | 1. 访问 `/browse` 进入浏览界面
114 | 2. 左侧选择历史方案
115 | 3. 右侧查看完整细节
116 | 4. 支持分页导航(每页20条)
117 |
118 | ## ⚙️ 配置说明
119 |
120 | ### 模型参数调整
121 | 修改 `app.py` 中的prompt模板:
122 | ```python
123 | # 示例生成模板
124 | prompt = f"""为毛利哥这个财经IP,策划财经方面的短视频《毛利哥的财经小课堂》,根据给的金点子内容,生成短视频所需要所有脚本。
125 | 要求为:
126 | =========
127 | 【标题】:要求抓人眼球,看了就想点视频
128 | 【精彩前五秒】:前5秒如何抓人眼球?
129 | 【脚本】该视频的脚本、导演方式、演员台词。
130 | =========
131 | 金点子为:{request.form['idea']}"""
132 | """
133 | ```
134 |
135 | ### 界面自定义
136 | 1. 修改 `static/style.css` 调整视觉样式
137 | 2. 编辑 `templates/` 下的HTML文件变更布局
138 | 3. 调整 `static/script.js` 实现交互逻辑
139 |
140 | ## 🤝 参与贡献
141 |
142 | 我们欢迎各种形式的贡献:
143 | 1. Fork项目并提交Pull Request
144 | 2. 提交Issue报告问题或建议
145 | 3. 完善文档或翻译
146 | 4. 测试并反馈使用体验
147 |
148 | 请遵循 [贡献指南](CONTRIBUTING.md) 进行操作。
149 |
150 | ## 📄 开源协议
151 |
152 | 本项目采用 [MIT License](LICENSE) 开源协议。
153 |
154 | ## Star History
155 |
156 | [](https://star-history.com/#handsomejustin/ai-video-script-generator&Date)
157 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | from flask import Flask, render_template, request, jsonify
2 | from pymysql.cursors import DictCursor # 确保导入DictCursor
3 | import logging
4 | import pymysql
5 | import requests
6 | import json
7 | import re
8 | from flask_limiter import Limiter
9 | from flask_limiter.util import get_remote_address
10 |
11 | app = Flask(__name__)
12 | # 初始化速率限制
13 | limiter = Limiter(app=app, key_func=get_remote_address)
14 |
15 | # 数据库配置
16 | db_config = {
17 | 'host': 'localhost',
18 | 'user': 'root',
19 | 'password': 'yourpassword',
20 | 'database': 'cehua',
21 | 'port': 3306,
22 | 'charset': 'utf8mb4'
23 | }
24 |
25 | def remove_think_tags(text):
26 | """
27 | 移除文本中所有标签及其内容
28 | 示例:
29 | >>> remove_think_tags('正常文本思考内容')
30 | '正常文本'
31 | >>> remove_think_tags('多标签内容')
32 | '多签'
33 | """
34 | return re.sub(r'.*?', '', text, flags=re.DOTALL)
35 |
36 | @app.route('/')
37 | def index():
38 | return render_template('index.html')
39 |
40 | @app.route('/generate', methods=['POST'])
41 | def generate_script():
42 | try:
43 | prompt = f"""为毛利哥这个财经IP,策划财经方面的短视频《毛利哥的财经小课堂》,根据给的金点子内容,生成短视频所需要所有脚本。
44 | 要求为:
45 | =========
46 | 【标题】:要求抓人眼球,看了就想点视频
47 | 【精彩前五秒】:前5秒如何抓人眼球?
48 | 【脚本】该视频的脚本、导演方式、演员台词。
49 | =========
50 | 金点子为:{request.form['idea']}"""
51 |
52 | response = requests.post(
53 | 'http://localhost:11434/api/generate',
54 | json={
55 | 'model': 'deepseek-r1:32b',
56 | 'prompt': prompt,
57 | 'stream': False
58 | }
59 | )
60 |
61 | if response.status_code == 200:
62 | result = json.loads(response.text)
63 | return jsonify({'raw_response': remove_think_tags(result['response'].strip())})
64 | else:
65 | return jsonify({'error': 'AI生成失败'}), 500
66 |
67 | except Exception as e:
68 | return jsonify({'error': str(e)}), 500
69 |
70 | @app.route('/extract', methods=['POST'])
71 | @limiter.limit("10 per minute")
72 | def extract_field():
73 | try:
74 | data = request.json
75 | field_type = request.json['field_type']
76 | raw_text = request.json['raw_text']
77 |
78 | prompts = {
79 | 'title': "请从以下文本中精确提取【标题】内容,只需返回标题文本不要包含其他内容:\n",
80 | 'opening': "请从以下文本中精确提取【精彩前五秒】内容,只需返回前五秒脚本不要包含其他内容:\n",
81 | 'script': "请从以下文本中精确提取【脚本】正文内容,不要包含导演方式和演员台词:\n",
82 | 'direction': "请从以下文本中精确提取【导演方式】内容,只需返回导演指导说明:\n",
83 | 'dialogue': "请从以下文本中精确提取【演员台词】内容,只需返回对话文本:\n"
84 | }
85 |
86 | response = requests.post(
87 | 'http://localhost:11434/api/generate',
88 | json={
89 | 'model': 'deepseek-r1:32b',
90 | 'prompt': f"{prompts[field_type]}{raw_text}",
91 | 'stream': False
92 | }
93 | )
94 |
95 | if response.status_code == 200:
96 | result = json.loads(response.text)
97 | return jsonify({'content': remove_think_tags(result['response'].strip())})
98 | else:
99 | return jsonify({'error': '字段提取失败'}), 500
100 |
101 | except Exception as e:
102 | return jsonify({'error': str(e)}), 500
103 |
104 | @app.route('/save', methods=['POST'])
105 | def save_to_db():
106 | try:
107 | conn = pymysql.connect(**db_config)
108 | cursor = conn.cursor()
109 |
110 | sql = """INSERT INTO todo_list
111 | (title, opening, script, direction, dialogue)
112 | VALUES (%s, %s, %s, %s, %s)"""
113 |
114 | cursor.execute(sql, (
115 | request.form['title'],
116 | request.form['opening'],
117 | request.form['script'],
118 | request.form['direction'],
119 | request.form['dialogue']
120 | ))
121 |
122 | conn.commit()
123 | return jsonify({'status': 'success'})
124 |
125 | except Exception as e:
126 | return jsonify({'status': 'error', 'message': str(e)})
127 | finally:
128 | cursor.close()
129 | conn.close()
130 |
131 | @app.route('/browse')
132 | def browse():
133 | page = request.args.get('page', 1, type=int)
134 | per_page = 20
135 |
136 | try:
137 | conn = pymysql.connect(**db_config)
138 | with conn.cursor() as cursor:
139 | # 获取总记录数
140 | cursor.execute("SELECT COUNT(*) FROM todo_list")
141 | total = cursor.fetchone()[0]
142 |
143 | # 计算分页
144 | total_pages = (total + per_page - 1) // per_page
145 | offset = (page - 1) * per_page
146 |
147 | # 获取当前页数据
148 | cursor.execute("SELECT id, title FROM todo_list ORDER BY created_at DESC LIMIT %s OFFSET %s",
149 | (per_page, offset))
150 | items = cursor.fetchall()
151 |
152 | return render_template('browse.html',
153 | items=items,
154 | current_page=page,
155 | total_pages=total_pages)
156 |
157 | except Exception as e:
158 | return str(e), 500
159 | finally:
160 | conn.close()
161 |
162 | @app.route('/get_detail/')
163 | def get_detail(item_id):
164 | conn = None # 显式初始化连接变量
165 | try:
166 | # 记录调试信息
167 | app.logger.debug(f"Attempting to connect to database with config: {db_config}")
168 |
169 | conn = pymysql.connect(**db_config)
170 |
171 | with conn.cursor(DictCursor) as cursor:
172 | app.logger.debug(f"Executing query for item_id: {item_id}")
173 | cursor.execute("SELECT * FROM todo_list WHERE id = %s", (item_id,))
174 | detail = cursor.fetchone()
175 |
176 | if not detail:
177 | app.logger.warning(f"Item {item_id} not found")
178 | return jsonify({"error": "Item not found"}), 404
179 |
180 | app.logger.debug(f"Successfully retrieved item {item_id}")
181 | return jsonify(detail)
182 |
183 | except pymysql.MySQLError as e:
184 | app.logger.error(f"Database error: {str(e)}")
185 | return jsonify({"error": "Database operation failed"}), 500
186 | except Exception as e:
187 | app.logger.error(f"Unexpected error: {str(e)}", exc_info=True)
188 | return jsonify({"error": "Internal server error"}), 500
189 | finally:
190 | if conn and conn.open: # 安全关闭连接
191 | app.logger.debug("Closing database connection")
192 | conn.close()
193 |
194 | if __name__ == '__main__':
195 | app.run(debug=True)
196 |
--------------------------------------------------------------------------------
/static/script.js:
--------------------------------------------------------------------------------
1 | // 页面加载时恢复数据
2 | document.addEventListener('DOMContentLoaded', () => {
3 | // 恢复生成按钮状态
4 | const savedRaw = localStorage.getItem('rawResponse');
5 | if (savedRaw) {
6 | document.getElementById('raw-response').value = savedRaw;
7 | }
8 |
9 | // 恢复各个字段
10 | ['title', 'opening', 'script', 'direction', 'dialogue'].forEach(id => {
11 | const savedValue = localStorage.getItem(id);
12 | if (savedValue) {
13 | document.getElementById(id).value = savedValue;
14 | }
15 | });
16 | });
17 |
18 | async function generateScript() {
19 | const idea = document.getElementById('idea').value;
20 |
21 | try {
22 | const btn = document.querySelector('.generate-btn');
23 | btn.disabled = true;
24 | btn.innerHTML = '生成中...';
25 |
26 | const response = await fetch('/generate', {
27 | method: 'POST',
28 | headers: {
29 | 'Content-Type': 'application/x-www-form-urlencoded',
30 | },
31 | body: new URLSearchParams({
32 | 'title_requirements': '要求抓人眼球,看了就想点视频',
33 | 'opening_requirements': '前5秒如何抓人眼球?',
34 | 'idea': idea
35 | })
36 | });
37 |
38 | const data = await response.json();
39 |
40 | if (data.error) {
41 | throw new Error(data.error);
42 | }
43 |
44 | // 保存到本地存储
45 | const cleanResponse = data.raw_response || '';
46 | document.getElementById('raw-response').value = cleanResponse;
47 | localStorage.setItem('rawResponse', cleanResponse);
48 |
49 | // 清空字段并移除旧存储
50 | ['title', 'opening', 'script', 'direction', 'dialogue'].forEach(id => {
51 | document.getElementById(id).value = '';
52 | localStorage.removeItem(id);
53 | });
54 |
55 | } catch (error) {
56 | alert('生成失败: ' + error.message);
57 | } finally {
58 | const btn = document.querySelector('.generate-btn');
59 | btn.disabled = false;
60 | btn.innerHTML = '🚀 生成策划方案';
61 | }
62 | }
63 |
64 | async function extractField(fieldType) {
65 | const rawText = document.getElementById('raw-response').value;
66 | const target = document.getElementById(fieldType);
67 | const btn = document.querySelector(`button[onclick="extractField('${fieldType}')"]`);
68 |
69 | if (!rawText) {
70 | alert('请先生成原始内容');
71 | return;
72 | }
73 |
74 | try {
75 | target.disabled = true;
76 | btn.disabled = true;
77 | btn.classList.add('loading');
78 |
79 | const response = await fetch('/extract', {
80 | method: 'POST',
81 | headers: {
82 | 'Content-Type': 'application/json',
83 | },
84 | body: JSON.stringify({
85 | field_type: fieldType,
86 | raw_text: rawText
87 | })
88 | });
89 |
90 | const data = await response.json();
91 |
92 | if (data.error) {
93 | throw new Error(data.error);
94 | }
95 |
96 | // 保存到本地存储并显示
97 | const cleanContent = data.content || '';
98 | target.value = cleanContent;
99 | localStorage.setItem(fieldType, cleanContent);
100 | showVisualFeedback(target, true);
101 |
102 | } catch (error) {
103 | showVisualFeedback(target, false);
104 | alert('提取失败: ' + error.message);
105 | } finally {
106 | target.disabled = false;
107 | btn.disabled = false;
108 | btn.classList.remove('loading');
109 | }
110 | }
111 |
112 | // 新增清除本地存储功能(可选)
113 | function clearStorage() {
114 | localStorage.removeItem('rawResponse');
115 | ['title', 'opening', 'script', 'direction', 'dialogue'].forEach(id => {
116 | localStorage.removeItem(id);
117 | });
118 | location.reload();
119 | }
120 |
121 | function showVisualFeedback(element, isSuccess) {
122 | element.style.borderColor = isSuccess ? '#27ae60' : '#e74c3c';
123 | element.style.boxShadow = `0 0 0 3px ${isSuccess ? 'rgba(39, 174, 96, 0.1)' : 'rgba(231, 76, 60, 0.1)'}`;
124 |
125 | setTimeout(() => {
126 | element.style.borderColor = '#e0e0e0';
127 | element.style.boxShadow = 'none';
128 | }, 1000);
129 | }
130 |
131 | async function saveData() {
132 | const formData = {
133 | title: document.getElementById('title').value,
134 | opening: document.getElementById('opening').value,
135 | script: document.getElementById('script').value,
136 | direction: document.getElementById('direction').value,
137 | dialogue: document.getElementById('dialogue').value
138 | };
139 |
140 | try {
141 | const btn = document.querySelector('.save-btn');
142 | btn.disabled = true;
143 | btn.innerHTML = '保存中...';
144 |
145 | const response = await fetch('/save', {
146 | method: 'POST',
147 | headers: {
148 | 'Content-Type': 'application/x-www-form-urlencoded',
149 | },
150 | body: new URLSearchParams(formData)
151 | });
152 |
153 | const result = await response.json();
154 | if (result.status === 'success') {
155 | alert('保存成功!');
156 | } else {
157 | throw new Error(result.message);
158 | }
159 | } catch (error) {
160 | alert('保存失败: ' + error.message);
161 | } finally {
162 | const btn = document.querySelector('.save-btn');
163 | btn.disabled = false;
164 | btn.innerHTML = '💾 保存到数据库';
165 | }
166 | }
167 |
168 | // JavaScript修改(static/script.js)
169 | const EXTRACTION_ORDER = [
170 | { id: 'title', name: '标题' },
171 | { id: 'opening', name: '精彩前五秒' },
172 | { id: 'script', name: '脚本' },
173 | { id: 'direction', name: '导演方式' },
174 | { id: 'dialogue', name: '演员台词' }
175 | ];
176 |
177 | async function autoExtractAll() {
178 | const rawText = document.getElementById('raw-response').value;
179 | const extractBtn = document.querySelector('.extract-btn');
180 |
181 | if (!rawText) {
182 | alert('请先生成原始内容');
183 | return;
184 | }
185 |
186 | try {
187 | // 禁用按钮并显示进度
188 | extractBtn.disabled = true;
189 | extractBtn.innerHTML = ' 正在准备...';
190 |
191 | // 顺序执行提取
192 | for (let i = 0; i < EXTRACTION_ORDER.length; i++) {
193 | const { id, name } = EXTRACTION_ORDER[i];
194 | const textarea = document.getElementById(id);
195 |
196 | // 更新进度显示
197 | extractBtn.innerHTML = ` 正在提取 ${name} (${i+1}/${EXTRACTION_ORDER.length})`;
198 | textarea.placeholder = `正在提取${name}...`;
199 |
200 | try {
201 | const response = await fetch('/extract', {
202 | method: 'POST',
203 | headers: { 'Content-Type': 'application/json' },
204 | body: JSON.stringify({
205 | field_type: id,
206 | raw_text: rawText
207 | })
208 | });
209 |
210 | const data = await response.json();
211 | textarea.value = data.content || '内容提取失败';
212 | textarea.style.borderColor = data.content ? '#27ae60' : '#e74c3c';
213 |
214 | } catch (error) {
215 | textarea.value = `提取错误: ${error.message}`;
216 | textarea.style.borderColor = '#e74c3c';
217 | }
218 | }
219 |
220 | } catch (error) {
221 | alert('提取流程异常: ' + error.message);
222 | } finally {
223 | // 恢复按钮状态
224 | extractBtn.disabled = false;
225 | extractBtn.innerHTML = '一键提取全部内容';
226 | // 2秒后重置边框颜色
227 | setTimeout(() => {
228 | EXTRACTION_ORDER.forEach(({ id }) => {
229 | document.getElementById(id).style.borderColor = '#e0e0e0';
230 | });
231 | }, 2000);
232 | }
233 | }
234 |
--------------------------------------------------------------------------------
/static/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --primary-color: #1a1a1a;
3 | --secondary-color: #2a2a2a;
4 | --accent-color: #00b4d8;
5 | --success-color: #00cc88;
6 | --background: #f8f9ff;
7 | --text-light: #ffffff;
8 | --font-stack: 'Inter', 'Helvetica Neue', system-ui, sans-serif;
9 | }
10 |
11 | body {
12 | font-family: var(--font-stack);
13 | background: linear-gradient(135deg, #f8f9ff 0%, #eef2ff 100%);
14 | margin: 0;
15 | padding: 4rem;
16 | line-height: 1.6;
17 | color: var(--primary-color);
18 | }
19 |
20 | .container {
21 | max-width: 1600px;
22 | margin: 0 auto;
23 | background: rgba(255, 255, 255, 0.95);
24 | padding: 4rem;
25 | border-radius: 2rem;
26 | box-shadow: 0 2rem 6rem rgba(0, 0, 0, 0.08);
27 | backdrop-filter: blur(20px);
28 | }
29 |
30 | .header {
31 | color: var(--primary-color);
32 | text-align: center;
33 | margin: 0 0 4rem;
34 | font-size: 3rem;
35 | font-weight: 700;
36 | letter-spacing: -0.05em;
37 | position: relative;
38 | }
39 |
40 | .header::after {
41 | content: '';
42 | display: block;
43 | width: 120px;
44 | height: 4px;
45 | background: var(--accent-color);
46 | margin: 2rem auto;
47 | border-radius: 2px;
48 | }
49 |
50 | .input-group {
51 | margin-bottom: 3rem;
52 | }
53 |
54 | label {
55 | display: block;
56 | margin-bottom: 1.2rem;
57 | color: var(--primary-color);
58 | font-weight: 600;
59 | font-size: 1.1rem;
60 | letter-spacing: 0.02em;
61 | }
62 |
63 | textarea, input {
64 | width: 100%;
65 | padding: 1.2rem;
66 | border: 2px solid #e0e3ff;
67 | border-radius: 1rem;
68 | font-family: var(--font-stack);
69 | font-size: 1.1rem;
70 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
71 | background: rgba(255, 255, 255, 0.8);
72 | }
73 |
74 | /* 不同字段高度设置 */
75 | #title { min-height: 60px; } /* 1行 */
76 | #opening { min-height: 250px; } /* 5行 */
77 | #script { min-height: 800px; } /* 20行 */
78 | #direction { min-height: 150px; } /* 5行 */
79 | #dialogue { min-height: 250px; } /* 5行 */
80 |
81 | textarea:focus, input:focus {
82 | border-color: var(--accent-color);
83 | box-shadow: 0 0 0 4px rgba(0, 180, 216, 0.15);
84 | background: white;
85 | }
86 |
87 | button {
88 | border: none;
89 | padding: 1rem 2rem;
90 | border-radius: 0.8rem;
91 | cursor: pointer;
92 | font-weight: 600;
93 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
94 | display: inline-flex;
95 | align-items: center;
96 | gap: 0.8rem;
97 | font-family: var(--font-stack);
98 | }
99 |
100 | .generate-btn {
101 | background: linear-gradient(135deg, var(--success-color) 0%, #00b894 100%);
102 | color: var(--text-light);
103 | width: 100%;
104 | padding: 1.5rem;
105 | margin: 2rem 0;
106 | font-size: 1.2rem;
107 | box-shadow: 0 1rem 2rem rgba(0, 204, 136, 0.15);
108 | }
109 |
110 | .generate-btn:hover {
111 | transform: translateY(-2px);
112 | box-shadow: 0 1.5rem 3rem rgba(0, 204, 136, 0.25);
113 | }
114 |
115 | .result-card {
116 | margin-top: 3rem;
117 | padding: 3rem;
118 | background: rgba(255, 255, 255, 0.9);
119 | border-radius: 1.5rem;
120 | border: 1px solid rgba(224, 227, 255, 0.5);
121 | }
122 |
123 | .input-wrapper {
124 | display: grid;
125 | grid-template-columns: 1fr auto;
126 | gap: 1.5rem;
127 | align-items: start;
128 | }
129 |
130 |
131 | /* 加载动画样式 */
132 | .extract-btn.loading {
133 | padding-right: 3.5rem; /* 为加载图标留出空间 */
134 | }
135 |
136 | .loading::after {
137 | content: "";
138 | position: absolute;
139 | right: 1rem;
140 | top: 50%;
141 | transform: translateY(-50%);
142 | width: 1.2rem;
143 | height: 1.2rem;
144 | border: 2px solid rgba(255, 255, 255, 0.6);
145 | border-top-color: transparent;
146 | border-radius: 50%;
147 | animation: spin 0.8s linear infinite;
148 | opacity: 0;
149 | transition: opacity 0.3s;
150 | }
151 |
152 | .extract-btn.loading::after {
153 | opacity: 1;
154 | }
155 |
156 | /* 增加加载时文字淡出效果 */
157 | .extract-btn .btn-text {
158 | transition: opacity 0.3s;
159 | }
160 |
161 | .extract-btn.loading .btn-text {
162 | opacity: 0.5;
163 | }
164 |
165 | .extract-btn:hover {
166 | transform: translateY(-1px);
167 | box-shadow: 0 1rem 2rem rgba(0, 180, 216, 0.2);
168 | }
169 |
170 | .save-btn {
171 | background: linear-gradient(135deg, #6c5ce7 0%, #4b37b0 100%);
172 | color: var(--text-light);
173 | width: 100%;
174 | margin-top: 3rem;
175 | padding: 1.2rem;
176 | font-size: 1.1rem;
177 | }
178 |
179 | .raw-response textarea {
180 | min-height: 200px;
181 | background: rgba(240, 242, 255, 0.5);
182 | }
183 |
184 | .loading::after {
185 | border: 2px solid rgba(255, 255, 255, 0.6);
186 | border-top-color: transparent;
187 | animation: spin 0.8s linear infinite;
188 | }
189 |
190 | @keyframes spin {
191 | to { transform: rotate(360deg); }
192 | }
193 |
194 | /* 新增响应式设计 */
195 | @media (max-width: 1200px) {
196 | .container {
197 | padding: 3rem;
198 | margin: 2rem;
199 | }
200 |
201 | .header {
202 | font-size: 2.5rem;
203 | }
204 | }
205 |
206 | /* 浏览页专用样式 */
207 | .browse-container {
208 | display: grid;
209 | grid-template-columns: 320px 1fr;
210 | gap: 2rem;
211 | max-width: 1600px;
212 | margin: 2rem auto;
213 | padding: 2rem;
214 | }
215 |
216 | .browse-sidebar {
217 | background: rgba(255, 255, 255, 0.95);
218 | border-radius: 1.5rem;
219 | padding: 2rem;
220 | box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.05);
221 | height: calc(100vh - 4rem);
222 | display: flex;
223 | flex-direction: column;
224 | }
225 |
226 | .browse-title {
227 | font-size: 1.8rem;
228 | color: var(--primary-color);
229 | margin-bottom: 1.5rem;
230 | padding-bottom: 1rem;
231 | border-bottom: 2px solid var(--accent-color);
232 | }
233 |
234 | .item-list {
235 | flex: 1;
236 | overflow-y: auto;
237 | margin-bottom: 1.5rem;
238 | }
239 |
240 | .list-item {
241 | display: flex;
242 | gap: 1rem;
243 | padding: 1.2rem;
244 | margin-bottom: 0.8rem;
245 | border-radius: 0.8rem;
246 | background: rgba(240, 242, 255, 0.3);
247 | transition: all 0.2s ease;
248 | cursor: pointer;
249 | }
250 |
251 | .list-item:hover {
252 | background: var(--accent-color);
253 | transform: translateX(5px);
254 | }
255 |
256 | .list-item:hover .item-title {
257 | color: white;
258 | }
259 |
260 | .item-index {
261 | width: 24px;
262 | height: 24px;
263 | background: var(--success-color);
264 | border-radius: 50%;
265 | color: white;
266 | display: flex;
267 | align-items: center;
268 | justify-content: center;
269 | flex-shrink: 0;
270 | }
271 |
272 | .item-title {
273 | font-weight: 500;
274 | color: var(--primary-color);
275 | line-height: 1.4;
276 | }
277 |
278 | .pagination {
279 | display: flex;
280 | justify-content: space-between;
281 | align-items: center;
282 | padding-top: 1.5rem;
283 | border-top: 1px solid #eee;
284 | }
285 |
286 | .page-btn {
287 | padding: 0.6rem 1.2rem;
288 | border-radius: 0.6rem;
289 | background: rgba(0, 180, 216, 0.1);
290 | color: var(--accent-color);
291 | transition: all 0.2s ease;
292 | }
293 |
294 | .page-btn:hover {
295 | background: var(--accent-color);
296 | color: white;
297 | }
298 |
299 | .page-info {
300 | color: #666;
301 | font-size: 0.9rem;
302 | }
303 |
304 | /* 右侧详情面板 */
305 | .detail-panel {
306 | background: rgba(255, 255, 255, 0.95);
307 | border-radius: 1.5rem;
308 | padding: 2rem;
309 | box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.05);
310 | }
311 |
312 | .detail-header {
313 | margin-bottom: 2rem;
314 | padding-bottom: 1.5rem;
315 | border-bottom: 2px solid var(--accent-color);
316 | }
317 |
318 | .detail-header h2 {
319 | font-size: 1.8rem;
320 | color: var(--primary-color);
321 | margin-bottom: 0.5rem;
322 | }
323 |
324 | .detail-meta {
325 | color: #666;
326 | font-size: 0.9rem;
327 | }
328 |
329 | .detail-section {
330 | margin-bottom: 2rem;
331 | }
332 |
333 | .detail-section label {
334 | display: block;
335 | font-weight: 600;
336 | color: var(--accent-color);
337 | margin-bottom: 0.8rem;
338 | font-size: 1.1rem;
339 | }
340 |
341 | .detail-text {
342 | background: rgba(240, 242, 255, 0.3);
343 | padding: 1.5rem;
344 | border-radius: 1rem;
345 | line-height: 1.6;
346 | white-space: pre-wrap;
347 | }
348 |
349 | .browse-link {
350 | display: block;
351 | text-align: center;
352 | margin: 2rem 0;
353 | color: var(--accent-color);
354 | font-weight: 500;
355 | transition: all 0.2s ease;
356 | }
357 |
358 | .browse-link:hover {
359 | transform: translateX(10px);
360 | opacity: 0.8;
361 | }
362 | /* 新增标题高亮样式 */
363 | .detail-section.highlight {
364 | background: rgba(0, 180, 216, 0.05);
365 | border-radius: 1rem;
366 | padding: 1.5rem;
367 | margin-bottom: 2rem;
368 | border: 2px solid var(--accent-color);
369 | }
370 |
371 | .detail-section.highlight label {
372 | font-size: 1.3rem;
373 | color: var(--accent-color);
374 | margin-bottom: 1rem;
375 | }
376 |
377 | .detail-section.highlight .detail-text {
378 | font-size: 1.4rem;
379 | font-weight: 600;
380 | color: var(--primary-color);
381 | }
382 |
383 | .extract-btn {
384 | position: relative;
385 | background: linear-gradient(135deg, var(--accent-color) 0%, #0096c7 100%);
386 | color: var(--text-light);
387 | padding: 1rem 2rem;
388 | margin: 1rem 0;
389 | width: 100%;
390 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
391 | margin-left:40px;
392 | }
393 |
394 | .extract-btn:hover {
395 | transform: translateY(-2px);
396 | box-shadow: 0 1rem 2rem rgba(0, 180, 216, 0.2);
397 | }
398 |
399 | .loader {
400 | display: inline-block;
401 | width: 16px;
402 | height: 16px;
403 | border: 2px solid rgba(255, 255, 255, 0.6);
404 | border-top-color: transparent;
405 | border-radius: 50%;
406 | animation: spin 0.8s linear infinite;
407 | margin-right: 0.5rem;
408 | }
409 |
410 | @keyframes spin {
411 | to { transform: rotate(360deg); }
412 | }
413 |
414 | .field-item textarea {
415 | min-height: 150px;
416 | margin-bottom: 2rem;
417 | transition: border-color 0.3s ease;
418 | }
419 |
420 | #script {
421 | min-height: 400px;
422 | }
423 |
424 | /* 响应式设计 */
425 | @media (max-width: 1200px) {
426 | .container {
427 | padding: 2rem;
428 | margin: 1rem;
429 | }
430 |
431 | .header {
432 | font-size: 2rem;
433 | }
434 | }
435 |
--------------------------------------------------------------------------------