├── app
├── static
│ ├── img
│ │ ├── favicon
│ │ │ ├── favicon.ico
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── apple-touch-icon.png
│ │ │ └── favicon.svg
│ │ ├── screenshot.png.png
│ │ └── avatars
│ │ │ ├── 1326866ba5a54bc58d41b38cc7cf1bc5.png
│ │ │ ├── 3829392e4ac6438d90017e065dafcd72.png
│ │ │ ├── 536d616fb485420891387da41931f3f1.png
│ │ │ ├── 8519f59c8837483cba16f6872e8a4e76.png
│ │ │ ├── e71373dfd7e44482add8f2e43aa7451f.png
│ │ │ └── ff4f07a24a6b425480fe2895330e32c7.png
│ ├── manifest.json
│ └── js
│ │ └── main.js
├── services
│ ├── ai
│ │ ├── __init__.py
│ │ ├── factory.py
│ │ ├── base_client.py
│ │ ├── custom_client.py
│ │ └── openai_client.py
│ ├── __init__.py
│ ├── tag_service.py
│ ├── admin_service.py
│ ├── user_service.py
│ ├── prompt_service.py
│ └── ai_service.py
├── __pycache__
│ └── __init__.cpython-312.pyc
├── routes
│ ├── __pycache__
│ │ ├── auth.cpython-312.pyc
│ │ └── main.cpython-312.pyc
│ ├── __init__.py
│ ├── main.py
│ ├── auth.py
│ ├── admin.py
│ ├── ai.py
│ └── user.py
├── database
│ ├── __init__.py
│ └── db.py
├── utils
│ ├── __init__.py
│ ├── helpers.py
│ ├── file_upload.py
│ ├── decorators.py
│ └── encryption.py
├── config.py
├── __init__.py
├── extensions.py
└── templates
│ ├── auth
│ ├── login.html
│ └── register.html
│ ├── admin
│ ├── users.html
│ └── invite_codes.html
│ ├── search.html
│ ├── user
│ ├── profile.html
│ └── ai_settings.html
│ ├── prompts
│ └── my_prompts.html
│ └── base.html
├── __pycache__
├── wsgi.cpython-312.pyc
└── simple_app.cpython-312.pyc
├── requirements.txt
├── run.py
├── wsgi.py
├── nginx.conf
├── .gitignore
├── docker-compose.yml
├── .dockerignore
├── Dockerfile
├── docker-entrypoint.sh
├── migrations
└── add_ai_configs_table.py
├── README.md
└── init_db.py
/app/static/img/favicon/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/static/img/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/static/img/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/static/img/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/services/ai/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | AI 服务模块
3 | 提供统一的 AI API 调用接口
4 | """
5 |
6 |
--------------------------------------------------------------------------------
/__pycache__/wsgi.cpython-312.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/__pycache__/wsgi.cpython-312.pyc
--------------------------------------------------------------------------------
/app/static/img/screenshot.png.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/static/img/screenshot.png.png
--------------------------------------------------------------------------------
/__pycache__/simple_app.cpython-312.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/__pycache__/simple_app.cpython-312.pyc
--------------------------------------------------------------------------------
/app/__pycache__/__init__.cpython-312.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/__pycache__/__init__.cpython-312.pyc
--------------------------------------------------------------------------------
/app/routes/__pycache__/auth.cpython-312.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/routes/__pycache__/auth.cpython-312.pyc
--------------------------------------------------------------------------------
/app/routes/__pycache__/main.cpython-312.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/routes/__pycache__/main.cpython-312.pyc
--------------------------------------------------------------------------------
/app/static/img/avatars/1326866ba5a54bc58d41b38cc7cf1bc5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/static/img/avatars/1326866ba5a54bc58d41b38cc7cf1bc5.png
--------------------------------------------------------------------------------
/app/static/img/avatars/3829392e4ac6438d90017e065dafcd72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/static/img/avatars/3829392e4ac6438d90017e065dafcd72.png
--------------------------------------------------------------------------------
/app/static/img/avatars/536d616fb485420891387da41931f3f1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/static/img/avatars/536d616fb485420891387da41931f3f1.png
--------------------------------------------------------------------------------
/app/static/img/avatars/8519f59c8837483cba16f6872e8a4e76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/static/img/avatars/8519f59c8837483cba16f6872e8a4e76.png
--------------------------------------------------------------------------------
/app/static/img/avatars/e71373dfd7e44482add8f2e43aa7451f.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/static/img/avatars/e71373dfd7e44482add8f2e43aa7451f.png
--------------------------------------------------------------------------------
/app/static/img/avatars/ff4f07a24a6b425480fe2895330e32c7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DEKVIW/prompt-manager/HEAD/app/static/img/avatars/ff4f07a24a6b425480fe2895330e32c7.png
--------------------------------------------------------------------------------
/app/database/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 数据库模块
3 | """
4 | from app.database.db import get_db, close_db, init_db_connection
5 |
6 | __all__ = ['get_db', 'close_db', 'init_db_connection']
7 |
8 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # Core framework
2 | Flask==2.2.5
3 | Werkzeug==2.2.3
4 |
5 | # Encryption and security
6 | cryptography==39.0.2
7 |
8 | # AI features
9 | openai>=1.0.0
10 | requests>=2.28.0
11 |
12 | # Production WSGI servers
13 | gunicorn==21.2.0
14 | waitress==2.1.2
--------------------------------------------------------------------------------
/run.py:
--------------------------------------------------------------------------------
1 | """
2 | 开发环境运行入口
3 | """
4 | import os
5 | from app import create_app
6 |
7 | app = create_app()
8 |
9 | if __name__ == '__main__':
10 | debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
11 | app.run(debug=debug_mode, host='0.0.0.0')
12 |
13 |
--------------------------------------------------------------------------------
/app/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 工具函数模块
3 | """
4 | from app.utils.decorators import login_required, admin_required
5 | from app.utils.helpers import format_datetime
6 | from app.utils.file_upload import save_avatar
7 |
8 | __all__ = ['login_required', 'admin_required', 'format_datetime', 'save_avatar']
9 |
10 |
--------------------------------------------------------------------------------
/app/routes/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 路由模块
3 | """
4 | from app.routes import main, auth, prompts, admin, user, ai
5 |
6 |
7 | def register_blueprints(app):
8 | """注册所有蓝图"""
9 | app.register_blueprint(main.bp)
10 | app.register_blueprint(auth.bp)
11 | app.register_blueprint(prompts.bp)
12 | app.register_blueprint(admin.bp)
13 | app.register_blueprint(user.bp)
14 | app.register_blueprint(ai.bp)
15 |
16 |
--------------------------------------------------------------------------------
/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI入口文件,用于生产环境部署
3 | """
4 | import os
5 | import sys
6 |
7 | # 添加当前目录到路径,确保可以导入模块
8 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9 |
10 | # 检查是否处于生产环境
11 | if os.environ.get('FLASK_DEBUG', 'False').lower() == 'true':
12 | print("警告:当前以调试模式运行WSGI应用。在生产环境中应禁用调试模式。")
13 |
14 | # 导入应用
15 | from app import create_app
16 |
17 | app = create_app()
18 |
19 | # 仅用于直接运行此文件时
20 | if __name__ == "__main__":
21 | app.run()
--------------------------------------------------------------------------------
/app/utils/helpers.py:
--------------------------------------------------------------------------------
1 | """
2 | 辅助函数
3 | """
4 | import datetime
5 |
6 |
7 | def format_datetime(dt):
8 | """将日期时间格式化为只显示年月日"""
9 | if isinstance(dt, datetime.datetime) or (isinstance(dt, str) and dt):
10 | try:
11 | if isinstance(dt, str):
12 | dt = datetime.datetime.fromisoformat(dt.replace('Z', '+00:00'))
13 | return dt.strftime('%Y-%m-%d')
14 | except (ValueError, AttributeError):
15 | return dt
16 | return '未知时间'
17 |
18 |
--------------------------------------------------------------------------------
/app/static/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "提示词管理平台",
3 | "short_name": "提示词",
4 | "description": "一站式收集、分享和探索AI提示词的专业工具",
5 | "start_url": "/",
6 | "display": "standalone",
7 | "background_color": "#ffffff",
8 | "theme_color": "#4361ee",
9 | "icons": [
10 | {
11 | "src": "/static/img/favicon/favicon-16x16.png",
12 | "sizes": "16x16",
13 | "type": "image/png"
14 | },
15 | {
16 | "src": "/static/img/favicon/favicon-32x32.png",
17 | "sizes": "32x32",
18 | "type": "image/png"
19 | },
20 | {
21 | "src": "/static/img/favicon/apple-touch-icon.png",
22 | "sizes": "180x180",
23 | "type": "image/png"
24 | }
25 | ]
26 | }
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name _;
4 |
5 | # 静态文件直接由Nginx提供服务
6 | location /static/ {
7 | root /var/www/html;
8 | expires 7d;
9 | add_header Cache-Control "public, max-age=604800";
10 | access_log off;
11 | }
12 |
13 | # 其他请求代理到Flask应用
14 | location / {
15 | proxy_pass http://127.0.0.1:5000;
16 | proxy_set_header Host $host;
17 | proxy_set_header X-Real-IP $remote_addr;
18 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
19 | proxy_set_header X-Forwarded-Proto $scheme;
20 | proxy_connect_timeout 300s;
21 | proxy_read_timeout 300s;
22 | }
23 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Python
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 | .Python
7 | env/
8 | build/
9 | develop-eggs/
10 | dist/
11 | downloads/
12 | eggs/
13 | .eggs/
14 | lib/
15 | lib64/
16 | parts/
17 | sdist/
18 | var/
19 | *.egg-info/
20 | .installed.cfg
21 | *.egg
22 |
23 | # Virtual Environment
24 | venv/
25 | ENV/
26 |
27 | # IDE
28 | .idea/
29 | .vscode/
30 | *.swp
31 | *.swo
32 |
33 | # Project specific
34 | instance/
35 | logs/
36 | *.db
37 | *.log
38 | *.sqlite3
39 |
40 | # OS specific
41 | .DS_Store
42 | Thumbs.db
43 |
44 | # 上传和静态生成文件
45 | uploads/
46 | static/img/avatars/
47 | static/img/covers/
48 |
49 | # 数据库
50 | data/
51 |
52 | # 其他
53 | *.bak
54 |
55 | # 文档和教程(已删除 Git教程.md)
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | prompt-manager:
5 | build:
6 | context: .
7 | dockerfile: Dockerfile
8 | image: prompt-manager:latest
9 | container_name: prompt-manager
10 | restart: unless-stopped
11 | user: root
12 | ports:
13 | - "5000:80"
14 | volumes:
15 | - ./instance:/app/instance
16 | - ./logs:/app/logs
17 | - ./uploads:/app/app/static/img/avatars
18 | environment:
19 | - SECRET_KEY=${SECRET_KEY:-change_this_to_a_random_string}
20 | - FLASK_DEBUG=false
21 | healthcheck:
22 | test: ["CMD", "curl", "-f", "http://localhost/health"]
23 | interval: 30s
24 | timeout: 10s
25 | retries: 3
26 |
--------------------------------------------------------------------------------
/app/utils/file_upload.py:
--------------------------------------------------------------------------------
1 | """
2 | 文件上传处理
3 | """
4 | import os
5 | import uuid
6 | from flask import current_app
7 |
8 |
9 | def save_avatar(file):
10 | """保存头像图片"""
11 | if not file or not file.filename:
12 | return None
13 |
14 | # 检查文件格式
15 | if '.' not in file.filename:
16 | return None
17 |
18 | # 获取文件扩展名
19 | ext = file.filename.rsplit('.', 1)[1].lower()
20 | if ext not in ['jpg', 'jpeg', 'png', 'gif']:
21 | return None
22 |
23 | # 生成随机文件名
24 | random_name = f"{uuid.uuid4().hex}.{ext}"
25 | save_path = os.path.join(current_app.config['UPLOAD_FOLDER'], random_name)
26 |
27 | # 保存文件
28 | file.save(save_path)
29 |
30 | # 返回相对URL路径
31 | return f"/static/img/avatars/{random_name}"
32 |
33 |
--------------------------------------------------------------------------------
/app/config.py:
--------------------------------------------------------------------------------
1 | """
2 | 应用配置
3 | """
4 | import os
5 | from pathlib import Path
6 |
7 |
8 | class Config:
9 | """基础配置类"""
10 | # 基础配置
11 | SECRET_KEY = os.environ.get('SECRET_KEY', 'dev_key')
12 | BASE_DIR = Path(__file__).parent.parent
13 |
14 | # 数据库配置
15 | DATABASE = str(BASE_DIR / 'instance' / 'prompts.db')
16 |
17 | # 日志配置
18 | LOG_DIR = str(BASE_DIR / 'logs')
19 | LOG_FILE = 'app.log'
20 | LOG_MAX_BYTES = 10 * 1024 * 1024 # 10MB
21 | LOG_BACKUP_COUNT = 10
22 |
23 | # 静态文件配置(现在在 app/ 目录下)
24 | STATIC_FOLDER = str(BASE_DIR / 'app' / 'static')
25 | UPLOAD_FOLDER = str(BASE_DIR / 'app' / 'static' / 'img' / 'avatars')
26 |
27 | # 安全配置
28 | ENABLE_CORS = os.environ.get('ENABLE_CORS', 'False').lower() == 'true'
29 | CORS_ORIGIN = os.environ.get('CORS_ORIGIN', '*')
30 | HTTPS_ENABLED = os.environ.get('HTTPS_ENABLED', 'False').lower() == 'true'
31 |
32 |
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 应用工厂函数
3 | """
4 | from flask import Flask
5 | from app.config import Config
6 | from app.extensions import init_extensions
7 | from app.routes import register_blueprints
8 | from app.database import init_db_connection
9 |
10 |
11 | def create_app(config_class=Config):
12 | """创建并配置Flask应用"""
13 | # 模板和静态文件现在在 app/ 目录下
14 | app = Flask(
15 | __name__,
16 | template_folder='templates',
17 | static_folder='static',
18 | static_url_path='/static'
19 | )
20 | app.config.from_object(config_class)
21 |
22 | # 初始化扩展
23 | init_extensions(app)
24 |
25 | # 初始化数据库连接
26 | init_db_connection(app)
27 |
28 | # 注册蓝图
29 | register_blueprints(app)
30 |
31 | # 初始化数据库表(在应用上下文中)
32 | with app.app_context():
33 | from app.routes.prompts import ensure_favorites_table
34 | ensure_favorites_table()
35 |
36 | return app
37 |
38 |
--------------------------------------------------------------------------------
/app/static/img/favicon/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/utils/decorators.py:
--------------------------------------------------------------------------------
1 | """
2 | 装饰器
3 | """
4 | from functools import wraps
5 | from flask import session, flash, redirect, url_for
6 | from app.database import get_db
7 |
8 |
9 | def login_required(f):
10 | """要求用户登录的装饰器"""
11 | @wraps(f)
12 | def decorated_function(*args, **kwargs):
13 | if 'user_id' not in session:
14 | flash('请先登录', 'danger')
15 | return redirect(url_for('auth.login'))
16 | return f(*args, **kwargs)
17 | return decorated_function
18 |
19 |
20 | def admin_required(f):
21 | """要求管理员权限的装饰器"""
22 | @wraps(f)
23 | def decorated_function(*args, **kwargs):
24 | if 'user_id' not in session:
25 | flash('请先登录', 'danger')
26 | return redirect(url_for('auth.login'))
27 |
28 | db = get_db()
29 | user = db.execute('SELECT * FROM users WHERE id = ?', (session['user_id'],)).fetchone()
30 |
31 | if not user or not user['is_admin']:
32 | flash('没有管理员权限', 'danger')
33 | return redirect(url_for('main.index'))
34 |
35 | return f(*args, **kwargs)
36 | return decorated_function
37 |
38 |
--------------------------------------------------------------------------------
/app/services/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | 业务逻辑层
3 | """
4 | from app.services.tag_service import process_tags, link_tags_to_prompt
5 | from app.services.prompt_service import (
6 | create_prompt, update_prompt, delete_prompt, get_prompt_by_id,
7 | get_user_prompts, get_public_prompts, is_favorited, toggle_favorite
8 | )
9 | from app.services.user_service import (
10 | authenticate_user, register_user, get_user_by_id, get_user_profile,
11 | update_user_profile, save_avatar, change_password
12 | )
13 | from app.services.admin_service import (
14 | generate_invite_code, generate_invite_codes, get_all_invite_codes,
15 | delete_invite_codes, get_all_users, ban_user, delete_user
16 | )
17 |
18 | __all__ = [
19 | # 标签服务
20 | 'process_tags', 'link_tags_to_prompt',
21 | # 提示词服务
22 | 'create_prompt', 'update_prompt', 'delete_prompt', 'get_prompt_by_id',
23 | 'get_user_prompts', 'get_public_prompts', 'is_favorited', 'toggle_favorite',
24 | # 用户服务
25 | 'authenticate_user', 'register_user', 'get_user_by_id', 'get_user_profile',
26 | 'update_user_profile', 'save_avatar', 'change_password',
27 | # 管理员服务
28 | 'generate_invite_code', 'generate_invite_codes', 'get_all_invite_codes',
29 | 'delete_invite_codes', 'get_all_users', 'ban_user', 'delete_user'
30 | ]
31 |
32 |
--------------------------------------------------------------------------------
/app/services/tag_service.py:
--------------------------------------------------------------------------------
1 | """
2 | 标签业务逻辑
3 | """
4 | import re
5 | from app.database import get_db
6 |
7 |
8 | def process_tags(tag_names):
9 | """处理标签名称列表,返回去重后的标签列表"""
10 | if not tag_names:
11 | return []
12 |
13 | all_tags = []
14 | for tag_field in tag_names:
15 | if tag_field.strip():
16 | # 支持多种分隔符:逗号、分号、空格、井号、斜杠、竖线、换行等
17 | separators = r'[,;,;、\s\n#\/\|·\-_\+\*~`]+'
18 | split_tags = re.split(separators, tag_field)
19 | all_tags.extend([tag.strip() for tag in split_tags if tag.strip()])
20 |
21 | # 去重
22 | return list(set(all_tags))
23 |
24 |
25 | def link_tags_to_prompt(db, prompt_id, tag_names):
26 | """将标签关联到提示词"""
27 | if not tag_names:
28 | return
29 |
30 | unique_tags = process_tags(tag_names)
31 |
32 | for tag_name in unique_tags:
33 | # 检查标签是否存在
34 | tag = db.execute('SELECT id FROM tags WHERE name = ?', (tag_name,)).fetchone()
35 | if not tag:
36 | # 创建新标签
37 | db.execute('INSERT INTO tags (name) VALUES (?)', (tag_name,))
38 | tag_id = db.execute('SELECT last_insert_rowid()').fetchone()[0]
39 | else:
40 | tag_id = tag['id']
41 |
42 | # 关联标签和提示词
43 | db.execute('INSERT INTO tags_prompts (tag_id, prompt_id) VALUES (?, ?)', (tag_id, prompt_id))
44 |
45 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Python缓存文件
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 |
7 | # 分发/打包
8 | .Python
9 | build/
10 | develop-eggs/
11 | dist/
12 | downloads/
13 | eggs/
14 | .eggs/
15 | lib/
16 | lib64/
17 | parts/
18 | sdist/
19 | var/
20 | wheels/
21 | *.egg-info/
22 | .installed.cfg
23 | *.egg
24 |
25 | # PyInstaller
26 | *.manifest
27 | *.spec
28 |
29 | # 单元测试/覆盖率报告
30 | htmlcov/
31 | .tox/
32 | .coverage
33 | .coverage.*
34 | .cache
35 | nosetests.xml
36 | coverage.xml
37 | *.cover
38 | .hypothesis/
39 | .pytest_cache/
40 |
41 | # 环境变量
42 | .env
43 | .venv
44 | env/
45 | venv/
46 | ENV/
47 | env.bak/
48 | venv.bak/
49 |
50 | # IDE文件
51 | .vscode/
52 | .idea/
53 | *.swp
54 | *.swo
55 | *~
56 |
57 | # 操作系统文件
58 | .DS_Store
59 | .DS_Store?
60 | ._*
61 | .Spotlight-V100
62 | .Trashes
63 | ehthumbs.db
64 | Thumbs.db
65 |
66 | # Git
67 | .git/
68 | .gitignore
69 |
70 | # 日志文件
71 | logs/
72 | *.log
73 |
74 | # 数据库文件
75 | *.db
76 | *.sqlite
77 | *.sqlite3
78 | instance/
79 | data/
80 |
81 | # 上传文件
82 | uploads/
83 |
84 | # 临时文件
85 | tmp/
86 | temp/
87 |
88 | # 文档
89 | *.md
90 | README.md
91 |
92 | # 脚本文件(本地开发脚本和Docker部署脚本已删除)
93 | *.bat
94 | *.sh
95 | # 保留Docker容器运行必需的脚本
96 | !docker-entrypoint.sh
97 |
98 | # 配置文件
99 | docker-compose.yml
100 | # 保留Docker相关配置文件
101 | !nginx.conf
102 |
103 | # 其他临时文件
104 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 使用Python官方镜像作为基础,避免Alpine编译问题
2 | FROM python:3.9-slim
3 |
4 | # 安装Nginx和其他必要的系统依赖
5 | RUN apt-get update && \
6 | apt-get install -y nginx curl && \
7 | apt-get clean && \
8 | rm -rf /var/lib/apt/lists/*
9 |
10 | # 设置工作目录
11 | WORKDIR /app
12 |
13 | # 创建必要的目录
14 | RUN mkdir -p /app/instance /app/logs /app/instance_init && \
15 | chmod -R 777 /app/instance /app/logs
16 |
17 | # 安装Python依赖
18 | COPY requirements.txt .
19 | RUN pip install --no-cache-dir --upgrade pip && \
20 | pip install --no-cache-dir -r requirements.txt
21 |
22 | # 复制应用代码
23 | COPY . /app/
24 |
25 | # 为Nginx配置静态文件(静态文件现在在 app/static/ 目录下)
26 | RUN mkdir -p /var/www/html/static && \
27 | cp -r /app/app/static/* /var/www/html/static/ && \
28 | chmod -R 755 /var/www/html/static
29 |
30 | # 配置Nginx
31 | COPY nginx.conf /etc/nginx/conf.d/default.conf
32 | RUN rm /etc/nginx/sites-enabled/default || true
33 |
34 | # 初始化数据库
35 | RUN python init_db.py && \
36 | cp -r /app/instance/* /app/instance_init/
37 |
38 | # 设置环境变量
39 | ENV FLASK_APP=wsgi.py \
40 | FLASK_DEBUG=False \
41 | PYTHONPATH=/app \
42 | PYTHONDONTWRITEBYTECODE=1 \
43 | PYTHONUNBUFFERED=1
44 |
45 | # 添加启动脚本
46 | COPY docker-entrypoint.sh /docker-entrypoint.sh
47 | RUN chmod +x /docker-entrypoint.sh
48 |
49 | # 暴露端口
50 | EXPOSE 80
51 |
52 | # 启动命令
53 | ENTRYPOINT ["/docker-entrypoint.sh"]
54 | CMD sh -c "nginx && gunicorn --workers=4 --threads=2 --bind 127.0.0.1:5000 wsgi:app"
--------------------------------------------------------------------------------
/app/database/db.py:
--------------------------------------------------------------------------------
1 | """
2 | 数据库连接和工具函数
3 | """
4 | import os
5 | import sqlite3
6 | from flask import g, current_app
7 |
8 |
9 | def get_db():
10 | """获取数据库连接"""
11 | # 确保头像目录存在
12 | avatars_dir = current_app.config.get('UPLOAD_FOLDER')
13 | if avatars_dir and not os.path.exists(avatars_dir):
14 | os.makedirs(avatars_dir, exist_ok=True)
15 |
16 | db = getattr(g, '_database', None)
17 | if db is None:
18 | db = g._database = sqlite3.connect(current_app.config['DATABASE'])
19 | db.row_factory = sqlite3.Row
20 |
21 | # 检查并添加avatar_url字段(如果不存在)
22 | _ensure_avatar_field(db)
23 |
24 | return db
25 |
26 |
27 | def _ensure_avatar_field(db):
28 | """确保users表有avatar_url字段"""
29 | cursor = db.cursor()
30 | result = cursor.execute("PRAGMA table_info(users)").fetchall()
31 | has_avatar_field = any(row[1] == 'avatar_url' for row in result)
32 |
33 | if not has_avatar_field:
34 | try:
35 | cursor.execute('ALTER TABLE users ADD COLUMN avatar_url TEXT')
36 | db.commit()
37 | current_app.logger.info("已添加avatar_url字段到users表")
38 | except sqlite3.Error as e:
39 | current_app.logger.error(f"添加avatar_url字段时出错: {e}")
40 |
41 |
42 | def close_db(error):
43 | """关闭数据库连接"""
44 | if hasattr(g, '_database'):
45 | g._database.close()
46 |
47 |
48 | def init_db_connection(app):
49 | """初始化数据库连接(注册teardown处理器)"""
50 | app.teardown_appcontext(close_db)
51 |
52 |
--------------------------------------------------------------------------------
/app/services/ai/factory.py:
--------------------------------------------------------------------------------
1 | """
2 | AI 客户端工厂
3 | 根据配置创建对应的 AI 客户端实例
4 | """
5 | from app.services.ai.base_client import BaseAIClient
6 | from app.services.ai.openai_client import OpenAIClient
7 | from app.services.ai.custom_client import CustomAPIClient
8 |
9 |
10 | class AIClientFactory:
11 | """AI 客户端工厂"""
12 |
13 | @staticmethod
14 | def create_client(provider: str, api_key: str, base_url: str = None,
15 | model: str = "gpt-3.5-turbo", temperature: float = 0.7,
16 | max_tokens: int = 500) -> BaseAIClient:
17 | """
18 | 根据提供商创建对应的客户端实例
19 |
20 | :param provider: 提供商名称('openai', 'custom')
21 | :param api_key: API 密钥
22 | :param base_url: 基础 URL(自定义 API 必需)
23 | :param model: 模型名称
24 | :param temperature: 温度参数
25 | :param max_tokens: 最大 token 数
26 | :return: AI 客户端实例
27 | :raises ValueError: 如果提供商不支持或参数无效
28 | """
29 | if provider == 'openai':
30 | return OpenAIClient(
31 | api_key=api_key,
32 | base_url=base_url,
33 | model=model,
34 | temperature=temperature,
35 | max_tokens=max_tokens
36 | )
37 | elif provider == 'custom':
38 | if not base_url:
39 | raise ValueError("自定义 API 必须提供 base_url")
40 | return CustomAPIClient(
41 | api_key=api_key,
42 | base_url=base_url,
43 | model=model,
44 | temperature=temperature,
45 | max_tokens=max_tokens
46 | )
47 | else:
48 | raise ValueError(f"不支持的提供商: {provider}")
49 |
50 |
--------------------------------------------------------------------------------
/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # 数据库目录
5 | INSTANCE_DIR="/app/instance"
6 | INIT_DIR="/app/instance_init"
7 |
8 | # 检查数据目录下是否有文件
9 | if [ -z "$(ls -A $INSTANCE_DIR)" ]; then
10 | echo "数据目录为空,正在从初始化数据复制..."
11 | cp -r $INIT_DIR/* $INSTANCE_DIR/
12 | echo "数据库初始化完成!"
13 | else
14 | echo "检测到现有数据库,跳过初始化"
15 | echo "执行数据库迁移以更新数据库结构..."
16 |
17 | # 执行所有迁移脚本(迁移脚本会检查表是否存在,可安全重复执行)
18 | if [ -d "/app/migrations" ]; then
19 | for migration in /app/migrations/*.py; do
20 | if [ -f "$migration" ]; then
21 | echo "执行迁移: $(basename $migration)"
22 | python "$migration" || {
23 | echo "警告: 迁移脚本 $(basename $migration) 执行失败,但继续启动..."
24 | }
25 | fi
26 | done
27 | echo "数据库迁移完成!"
28 | fi
29 | fi
30 |
31 | # 确保权限正确
32 | chmod -R 777 $INSTANCE_DIR /app/logs
33 |
34 | # 确保上传目录存在(静态文件现在在 app/static/ 目录下)
35 | mkdir -p /app/app/static/img/avatars
36 | chmod -R 777 /app/app/static/img/avatars
37 |
38 | # 确保Nginx静态目录存在
39 | mkdir -p /var/www/html/static/img/avatars
40 |
41 | # 创建从应用上传目录到Nginx静态目录的软链接
42 | rm -rf /var/www/html/static/img/avatars
43 | ln -sf /app/app/static/img/avatars /var/www/html/static/img/
44 |
45 | # 配置Nginx
46 | if [ ! -f "/etc/nginx/sites-enabled/default" ]; then
47 | mkdir -p /etc/nginx/sites-enabled/
48 | ln -sf /etc/nginx/conf.d/default.conf /etc/nginx/sites-enabled/default
49 | fi
50 |
51 | # 确保Nginx有权限运行
52 | mkdir -p /var/log/nginx
53 | touch /var/log/nginx/access.log /var/log/nginx/error.log
54 | chown -R www-data:www-data /var/log/nginx
55 | chmod -R 755 /var/log/nginx
56 |
57 | echo "-------------------------------------"
58 | echo "正在启动Nginx和Gunicorn服务..."
59 | echo "Nginx将处理静态资源并反向代理请求到Flask应用"
60 | echo "-------------------------------------"
61 |
62 | # 执行传入的命令(通常是启动Nginx和Gunicorn)
63 | exec "$@"
--------------------------------------------------------------------------------
/app/services/ai/base_client.py:
--------------------------------------------------------------------------------
1 | """
2 | AI 客户端抽象基类
3 | 定义统一的 AI API 调用接口
4 | """
5 | from abc import ABC, abstractmethod
6 | from typing import Dict, Optional
7 |
8 |
9 | class BaseAIClient(ABC):
10 | """AI 客户端抽象基类"""
11 |
12 | def __init__(self, api_key: str, base_url: Optional[str] = None,
13 | model: str = "gpt-3.5-turbo", temperature: float = 0.7,
14 | max_tokens: int = 500):
15 | """
16 | 初始化 AI 客户端
17 |
18 | :param api_key: API 密钥
19 | :param base_url: 基础 URL(可选,用于自定义 API)
20 | :param model: 模型名称
21 | :param temperature: 温度参数(0.0-2.0)
22 | :param max_tokens: 最大 token 数
23 | """
24 | self.api_key = api_key
25 | self.base_url = base_url
26 | self.model = model
27 | self.temperature = temperature
28 | self.max_tokens = max_tokens
29 |
30 | @abstractmethod
31 | def chat_completion(self, messages: list, **kwargs) -> Dict:
32 | """
33 | 发送聊天补全请求
34 |
35 | :param messages: 消息列表,格式:[{"role": "user", "content": "..."}]
36 | :param kwargs: 其他参数(temperature, max_tokens 等)
37 | :return: API 响应字典
38 | """
39 | pass
40 |
41 | @abstractmethod
42 | def test_connection(self) -> bool:
43 | """
44 | 测试 API 连接
45 |
46 | :return: True 如果连接成功,False 否则
47 | """
48 | pass
49 |
50 | def extract_text_from_response(self, response: Dict) -> str:
51 | """
52 | 从 API 响应中提取文本内容
53 | 不同提供商的响应格式可能不同,子类可以重写此方法
54 |
55 | :param response: API 响应字典
56 | :return: 提取的文本内容
57 | """
58 | # 默认实现,子类可以覆盖
59 | if 'choices' in response and len(response['choices']) > 0:
60 | return response['choices'][0].get('message', {}).get('content', '')
61 | return ''
62 |
63 |
--------------------------------------------------------------------------------
/migrations/add_ai_configs_table.py:
--------------------------------------------------------------------------------
1 | """
2 | 数据库迁移脚本:添加 ai_configs 表
3 | 用于存储用户的 AI 配置信息
4 | """
5 | import os
6 | import sqlite3
7 | import sys
8 |
9 | def migrate():
10 | """执行数据库迁移"""
11 | db_path = "instance"
12 | db_file = os.path.join(db_path, "prompts.db")
13 |
14 | if not os.path.exists(db_file):
15 | print(f"错误:数据库文件不存在: {db_file}")
16 | print("请先运行 python init_db.py 初始化数据库")
17 | return False
18 |
19 | try:
20 | conn = sqlite3.connect(db_file)
21 | cursor = conn.cursor()
22 |
23 | # 检查表是否已存在
24 | cursor.execute("""
25 | SELECT name FROM sqlite_master
26 | WHERE type='table' AND name='ai_configs'
27 | """)
28 |
29 | if cursor.fetchone():
30 | print("ai_configs 表已存在,跳过迁移")
31 | conn.close()
32 | return True
33 |
34 | print("开始创建 ai_configs 表...")
35 |
36 | # 创建 ai_configs 表
37 | cursor.execute('''
38 | CREATE TABLE ai_configs (
39 | id INTEGER PRIMARY KEY AUTOINCREMENT,
40 | user_id INTEGER NOT NULL UNIQUE,
41 | provider TEXT NOT NULL DEFAULT 'openai',
42 | api_key TEXT NOT NULL,
43 | base_url TEXT,
44 | model TEXT DEFAULT 'gpt-3.5-turbo',
45 | temperature REAL DEFAULT 0.7,
46 | max_tokens INTEGER DEFAULT 500,
47 | enabled BOOLEAN DEFAULT 1,
48 | title_max_length INTEGER DEFAULT 30,
49 | description_max_length INTEGER DEFAULT 100,
50 | tag_count INTEGER DEFAULT 5,
51 | tag_two_char_count INTEGER DEFAULT 0,
52 | tag_four_char_count INTEGER DEFAULT 0,
53 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
54 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
55 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
56 | )
57 | ''')
58 |
59 | # 创建索引
60 | cursor.execute('''
61 | CREATE INDEX idx_ai_configs_user_id ON ai_configs(user_id)
62 | ''')
63 |
64 | conn.commit()
65 | conn.close()
66 |
67 | print("✓ ai_configs 表创建成功!")
68 | return True
69 |
70 | except Exception as e:
71 | print(f"✗ 迁移失败: {e}")
72 | import traceback
73 | traceback.print_exc()
74 | return False
75 |
76 | if __name__ == "__main__":
77 | success = migrate()
78 | sys.exit(0 if success else 1)
79 |
80 |
--------------------------------------------------------------------------------
/app/extensions.py:
--------------------------------------------------------------------------------
1 | """
2 | 扩展初始化
3 | """
4 | import os
5 | import logging
6 | from logging.handlers import RotatingFileHandler
7 | from flask import Response
8 |
9 |
10 | def init_extensions(app):
11 | """初始化所有扩展"""
12 | setup_logging(app)
13 | setup_security_headers(app)
14 | setup_context_processors(app)
15 |
16 |
17 | def setup_logging(app):
18 | """配置日志"""
19 | debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
20 | log_level = logging.DEBUG if debug_mode else logging.INFO
21 |
22 | # 配置根日志记录器
23 | logging.basicConfig(
24 | level=log_level,
25 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
26 | )
27 |
28 | # 在生产环境下,添加文件处理程序
29 | if not debug_mode:
30 | # 确保日志目录存在
31 | logs_dir = app.config['LOG_DIR']
32 | os.makedirs(logs_dir, exist_ok=True)
33 |
34 | # 创建rotating file handler
35 | file_handler = RotatingFileHandler(
36 | os.path.join(logs_dir, app.config['LOG_FILE']),
37 | maxBytes=app.config['LOG_MAX_BYTES'],
38 | backupCount=app.config['LOG_BACKUP_COUNT']
39 | )
40 | file_handler.setLevel(log_level)
41 | file_handler.setFormatter(logging.Formatter(
42 | '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
43 | ))
44 |
45 | app.logger.addHandler(file_handler)
46 |
47 | app.logger.info(f"应用启动于{'调试' if debug_mode else '生产'}模式")
48 |
49 |
50 | def setup_security_headers(app):
51 | """配置安全响应头"""
52 | @app.after_request
53 | def add_security_headers(response: Response):
54 | # CORS配置
55 | if app.config['ENABLE_CORS']:
56 | response.headers['Access-Control-Allow-Origin'] = app.config['CORS_ORIGIN']
57 | response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
58 | response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
59 |
60 | # 基本安全头
61 | response.headers['X-Content-Type-Options'] = 'nosniff'
62 | response.headers['X-Frame-Options'] = 'SAMEORIGIN'
63 | response.headers['X-XSS-Protection'] = '1; mode=block'
64 |
65 | # HTTPS安全头
66 | if app.config['HTTPS_ENABLED']:
67 | response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
68 |
69 | return response
70 |
71 |
72 | def setup_context_processors(app):
73 | """配置上下文处理器"""
74 | import datetime
75 |
76 | @app.context_processor
77 | def inject_now():
78 | return {'now': datetime.datetime.now()}
79 |
80 |
--------------------------------------------------------------------------------
/app/services/ai/custom_client.py:
--------------------------------------------------------------------------------
1 | """
2 | 自定义 API 客户端
3 | 使用 requests 调用兼容 OpenAI 格式的 API
4 | """
5 | import requests
6 | from typing import Dict, Optional
7 | from app.services.ai.base_client import BaseAIClient
8 |
9 |
10 | class CustomAPIClient(BaseAIClient):
11 | """自定义 API 客户端(使用 requests,兼容 OpenAI 格式)"""
12 |
13 | def __init__(self, api_key: str, base_url: str,
14 | model: str = "gpt-3.5-turbo", temperature: float = 0.7,
15 | max_tokens: int = 500):
16 | """
17 | 初始化自定义 API 客户端
18 |
19 | :param api_key: API 密钥
20 | :param base_url: 基础 URL(必需)
21 | :param model: 模型名称
22 | :param temperature: 温度参数
23 | :param max_tokens: 最大 token 数
24 | """
25 | if not base_url:
26 | raise ValueError("自定义 API 必须提供 base_url")
27 |
28 | super().__init__(api_key, base_url, model, temperature, max_tokens)
29 |
30 | # 确保 base_url 格式正确
31 | self.base_url = self.base_url.rstrip('/')
32 | if not self.base_url.endswith('/chat/completions'):
33 | self.base_url = self.base_url + '/chat/completions'
34 |
35 | def chat_completion(self, messages: list, **kwargs) -> Dict:
36 | """
37 | 发送聊天补全请求(兼容 OpenAI 格式)
38 |
39 | :param messages: 消息列表
40 | :param kwargs: 其他参数
41 | :return: API 响应字典
42 | """
43 | headers = {
44 | "Authorization": f"Bearer {self.api_key}",
45 | "Content-Type": "application/json"
46 | }
47 |
48 | data = {
49 | "model": self.model,
50 | "messages": messages,
51 | "temperature": kwargs.get('temperature', self.temperature),
52 | "max_tokens": kwargs.get('max_tokens', self.max_tokens),
53 | "response_format": {"type": "json_object"} # 强制 JSON 格式
54 | }
55 |
56 | try:
57 | response = requests.post(
58 | self.base_url,
59 | headers=headers,
60 | json=data,
61 | timeout=30
62 | )
63 | response.raise_for_status()
64 | return response.json()
65 | except requests.exceptions.RequestException as e:
66 | raise Exception(f"API 调用失败: {str(e)}")
67 |
68 | def test_connection(self) -> bool:
69 | """
70 | 测试 API 连接
71 |
72 | :return: True 如果连接成功,False 否则
73 | """
74 | try:
75 | self.chat_completion([{"role": "user", "content": "test"}], max_tokens=5)
76 | return True
77 | except Exception:
78 | return False
79 |
80 |
--------------------------------------------------------------------------------
/app/services/ai/openai_client.py:
--------------------------------------------------------------------------------
1 | """
2 | OpenAI API 客户端
3 | 使用官方 OpenAI SDK
4 | """
5 | from openai import OpenAI
6 | from typing import Dict, Optional
7 | from app.services.ai.base_client import BaseAIClient
8 |
9 |
10 | class OpenAIClient(BaseAIClient):
11 | """OpenAI API 客户端(使用官方 SDK)"""
12 |
13 | def __init__(self, api_key: str, base_url: Optional[str] = None,
14 | model: str = "gpt-3.5-turbo", temperature: float = 0.7,
15 | max_tokens: int = 500):
16 | """
17 | 初始化 OpenAI 客户端
18 |
19 | :param api_key: OpenAI API 密钥
20 | :param base_url: 自定义基础 URL(可选,用于代理或兼容 API)
21 | :param model: 模型名称
22 | :param temperature: 温度参数
23 | :param max_tokens: 最大 token 数
24 | """
25 | super().__init__(api_key, base_url, model, temperature, max_tokens)
26 |
27 | # 初始化 OpenAI 客户端
28 | client_kwargs = {"api_key": self.api_key}
29 | if self.base_url:
30 | client_kwargs["base_url"] = self.base_url
31 |
32 | self.client = OpenAI(**client_kwargs)
33 |
34 | def chat_completion(self, messages: list, **kwargs) -> Dict:
35 | """
36 | 发送聊天补全请求
37 |
38 | :param messages: 消息列表
39 | :param kwargs: 其他参数
40 | :return: API 响应字典
41 | """
42 | try:
43 | response = self.client.chat.completions.create(
44 | model=self.model,
45 | messages=messages,
46 | temperature=kwargs.get('temperature', self.temperature),
47 | max_tokens=kwargs.get('max_tokens', self.max_tokens),
48 | response_format={"type": "json_object"} # 强制 JSON 格式
49 | )
50 |
51 | # 转换为统一的字典格式
52 | return {
53 | 'choices': [{
54 | 'message': {
55 | 'content': response.choices[0].message.content
56 | }
57 | }]
58 | }
59 | except Exception as e:
60 | raise Exception(f"OpenAI API 调用失败: {str(e)}")
61 |
62 | def test_connection(self) -> bool:
63 | """
64 | 测试 API 连接
65 |
66 | :return: True 如果连接成功,False 否则
67 | """
68 | try:
69 | response = self.client.chat.completions.create(
70 | model=self.model,
71 | messages=[{"role": "user", "content": "test"}],
72 | max_tokens=5
73 | )
74 | return True
75 | except Exception:
76 | return False
77 |
78 | def extract_text_from_response(self, response: Dict) -> str:
79 | """
80 | 从响应中提取文本
81 |
82 | :param response: API 响应字典
83 | :return: 文本内容
84 | """
85 | if 'choices' in response and len(response['choices']) > 0:
86 | return response['choices'][0].get('message', {}).get('content', '')
87 | return ''
88 |
89 |
--------------------------------------------------------------------------------
/app/utils/encryption.py:
--------------------------------------------------------------------------------
1 | """
2 | 加密工具模块
3 | 用于加密和解密敏感数据(如 API Key)
4 | """
5 | import os
6 | import base64
7 | from cryptography.fernet import Fernet
8 | from cryptography.hazmat.primitives import hashes
9 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
10 | from cryptography.hazmat.backends import default_backend
11 |
12 |
13 | class Encryption:
14 | """加密工具类"""
15 |
16 | _key = None
17 |
18 | @classmethod
19 | def get_key(cls) -> bytes:
20 | """
21 | 获取加密密钥
22 | 优先从环境变量获取,否则使用默认密钥(仅用于开发环境)
23 | """
24 | if cls._key is not None:
25 | return cls._key
26 |
27 | # 尝试从环境变量获取密钥
28 | env_key = os.environ.get('AI_ENCRYPTION_KEY')
29 |
30 | if env_key:
31 | try:
32 | # 如果环境变量是 base64 编码的,直接使用
33 | cls._key = base64.urlsafe_b64decode(env_key.encode())
34 | return cls._key
35 | except Exception:
36 | # 如果不是 base64,使用 PBKDF2 派生密钥
37 | kdf = PBKDF2HMAC(
38 | algorithm=hashes.SHA256(),
39 | length=32,
40 | salt=b'prompt_manager_salt', # 固定盐值(生产环境应使用随机盐)
41 | iterations=100000,
42 | backend=default_backend()
43 | )
44 | cls._key = base64.urlsafe_b64encode(kdf.derive(env_key.encode()))
45 | return cls._key
46 |
47 | # 开发环境默认密钥(生产环境必须设置环境变量)
48 | default_key = b'dev_key_for_prompt_manager_encryption_12345678'
49 | cls._key = base64.urlsafe_b64encode(default_key[:32])
50 | return cls._key
51 |
52 | @classmethod
53 | def generate_key(cls) -> str:
54 | """
55 | 生成新的加密密钥(base64 编码)
56 | 用于生产环境配置
57 | """
58 | key = Fernet.generate_key()
59 | return base64.urlsafe_b64encode(key).decode()
60 |
61 | @classmethod
62 | def encrypt(cls, plaintext: str) -> str:
63 | """
64 | 加密字符串
65 |
66 | :param plaintext: 明文
67 | :return: 加密后的 base64 编码字符串
68 | """
69 | if not plaintext:
70 | return ''
71 |
72 | key = cls.get_key()
73 | f = Fernet(key)
74 | encrypted = f.encrypt(plaintext.encode())
75 | return base64.urlsafe_b64encode(encrypted).decode()
76 |
77 | @classmethod
78 | def decrypt(cls, ciphertext: str) -> str:
79 | """
80 | 解密字符串
81 |
82 | :param ciphertext: 加密的 base64 编码字符串
83 | :return: 解密后的明文
84 | """
85 | if not ciphertext:
86 | return ''
87 |
88 | try:
89 | key = cls.get_key()
90 | f = Fernet(key)
91 | encrypted_bytes = base64.urlsafe_b64decode(ciphertext.encode())
92 | decrypted = f.decrypt(encrypted_bytes)
93 | return decrypted.decode()
94 | except Exception as e:
95 | # 如果解密失败,可能是旧格式或损坏的数据
96 | raise ValueError(f"解密失败: {str(e)}")
97 |
98 |
99 | # 便捷函数
100 | def encrypt_string(plaintext: str) -> str:
101 | """加密字符串的便捷函数"""
102 | return Encryption.encrypt(plaintext)
103 |
104 |
105 | def decrypt_string(ciphertext: str) -> str:
106 | """解密字符串的便捷函数"""
107 | return Encryption.decrypt(ciphertext)
108 |
109 |
--------------------------------------------------------------------------------
/app/services/admin_service.py:
--------------------------------------------------------------------------------
1 | """
2 | 管理员业务逻辑
3 | """
4 | from app.database import get_db
5 | import random
6 | import string
7 |
8 |
9 | def generate_invite_code(creator_id):
10 | """生成单个邀请码"""
11 | code = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(8))
12 | db = get_db()
13 | db.execute(
14 | 'INSERT INTO invite_codes (code, creator_id) VALUES (?, ?)',
15 | (code, creator_id)
16 | )
17 | db.commit()
18 | return code
19 |
20 |
21 | def generate_invite_codes(creator_id, quantity):
22 | """批量生成邀请码"""
23 | codes = []
24 | db = get_db()
25 |
26 | for _ in range(quantity):
27 | code = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(8))
28 | db.execute(
29 | 'INSERT INTO invite_codes (code, creator_id) VALUES (?, ?)',
30 | (code, creator_id)
31 | )
32 | codes.append(code)
33 |
34 | db.commit()
35 | return codes
36 |
37 |
38 | def get_all_invite_codes():
39 | """获取所有邀请码"""
40 | db = get_db()
41 | invite_codes = db.execute(
42 | 'SELECT ic.*, u1.username as creator_username, u2.username as used_by_username '
43 | 'FROM invite_codes ic '
44 | 'LEFT JOIN users u1 ON ic.creator_id = u1.id '
45 | 'LEFT JOIN users u2 ON ic.used_by = u2.id '
46 | 'ORDER BY ic.created_at DESC'
47 | ).fetchall()
48 |
49 | return [dict(row) for row in invite_codes]
50 |
51 |
52 | def delete_invite_codes(code_ids):
53 | """删除邀请码"""
54 | db = get_db()
55 |
56 | if not code_ids:
57 | return 0
58 |
59 | try:
60 | placeholders = ','.join(['?'] * len(code_ids))
61 | query = f'DELETE FROM invite_codes WHERE code IN ({placeholders})'
62 | result = db.execute(query, code_ids)
63 | deleted_count = result.rowcount
64 | db.commit()
65 | return deleted_count
66 | except Exception as e:
67 | db.rollback()
68 | raise Exception(f'删除邀请码时出错: {str(e)}')
69 |
70 |
71 | def get_all_users():
72 | """获取所有用户"""
73 | db = get_db()
74 | users = db.execute('SELECT * FROM users ORDER BY id').fetchall()
75 | return [dict(row) for row in users]
76 |
77 |
78 | def ban_user(user_id, is_banned):
79 | """封禁/解封用户"""
80 | db = get_db()
81 |
82 | user = db.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
83 | if not user:
84 | raise ValueError('用户不存在')
85 |
86 | if user['is_admin']:
87 | raise PermissionError('不能封禁管理员账号')
88 |
89 | # 检查字段是否存在
90 | columns = [column[1] for column in db.execute('PRAGMA table_info(users)').fetchall()]
91 | if 'is_banned' not in columns:
92 | db.execute('ALTER TABLE users ADD COLUMN is_banned BOOLEAN DEFAULT 0')
93 |
94 | db.execute('UPDATE users SET is_banned = ? WHERE id = ?', (is_banned, user_id))
95 | db.commit()
96 |
97 | return dict(user)
98 |
99 |
100 | def delete_user(user_id):
101 | """删除用户"""
102 | db = get_db()
103 |
104 | user = db.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
105 | if not user:
106 | raise ValueError('用户不存在')
107 |
108 | if user['is_admin']:
109 | raise PermissionError('不能删除管理员账号')
110 |
111 | try:
112 | # 删除用户创建的提示词
113 | prompts = db.execute('SELECT id FROM prompts WHERE user_id = ?', (user_id,)).fetchall()
114 | for prompt in prompts:
115 | db.execute('DELETE FROM tags_prompts WHERE prompt_id = ?', (prompt['id'],))
116 |
117 | db.execute('DELETE FROM prompts WHERE user_id = ?', (user_id,))
118 | db.execute('UPDATE invite_codes SET used_by = NULL WHERE used_by = ?', (user_id,))
119 | db.execute('DELETE FROM favorites WHERE user_id = ?', (user_id,))
120 | db.execute('DELETE FROM users WHERE id = ?', (user_id,))
121 | db.commit()
122 |
123 | return dict(user)
124 | except Exception as e:
125 | db.rollback()
126 | raise Exception(f'删除用户时出错: {str(e)}')
127 |
128 |
--------------------------------------------------------------------------------
/app/routes/main.py:
--------------------------------------------------------------------------------
1 | """
2 | 主页面路由
3 | """
4 | from flask import Blueprint, render_template
5 | from app.database import get_db
6 | from app.utils.helpers import format_datetime
7 | import datetime
8 |
9 | bp = Blueprint('main', __name__)
10 |
11 |
12 | @bp.route('/')
13 | def index():
14 | """首页"""
15 | popular_prompts = []
16 | popular_tags = []
17 |
18 | # 统计数据
19 | prompt_count = 0
20 | user_count = 0
21 | view_count = 0
22 |
23 | try:
24 | db = get_db()
25 |
26 | # 首先检查并创建prompt_tags表(如果不存在)
27 | db.execute('''
28 | CREATE TABLE IF NOT EXISTS prompt_tags (
29 | id INTEGER PRIMARY KEY AUTOINCREMENT,
30 | prompt_id INTEGER,
31 | tag TEXT,
32 | FOREIGN KEY (prompt_id) REFERENCES prompts (id) ON DELETE CASCADE
33 | )
34 | ''')
35 |
36 | # 获取热门提示词
37 | popular_prompts = db.execute('''
38 | SELECT p.id, p.title, p.description, p.view_count as views, p.is_public, p.created_at,
39 | u.id as user_id, u.username
40 | FROM prompts p
41 | JOIN users u ON p.user_id = u.id
42 | WHERE p.is_public = 1
43 | ORDER BY p.view_count DESC LIMIT 6
44 | ''').fetchall()
45 |
46 | # 将 Row 对象转换为可修改的字典
47 | popular_prompts = [dict(row) for row in popular_prompts]
48 |
49 | # 格式化时间
50 | for prompt in popular_prompts:
51 | if 'created_at' in prompt and prompt['created_at']:
52 | prompt['created_at'] = format_datetime(prompt['created_at'])
53 | if 'updated_at' in prompt and prompt['updated_at']:
54 | prompt['updated_at'] = format_datetime(prompt['updated_at'])
55 |
56 | # 为每个提示词获取标签
57 | for prompt in popular_prompts:
58 | prompt_id = prompt['id']
59 | tags = db.execute('''
60 | SELECT t.name
61 | FROM tags t
62 | JOIN tags_prompts tp ON t.id = tp.tag_id
63 | WHERE tp.prompt_id = ?
64 | ''', (prompt_id,)).fetchall()
65 |
66 | # 转换标签为列表
67 | prompt['tags'] = [tag['name'] for tag in tags] if tags else []
68 |
69 | # 获取热门标签
70 | popular_tags = db.execute('''
71 | SELECT t.name, COUNT(tp.prompt_id) as count
72 | FROM tags t
73 | JOIN tags_prompts tp ON t.id = tp.tag_id
74 | JOIN prompts p ON tp.prompt_id = p.id
75 | WHERE p.is_public = 1
76 | GROUP BY t.name
77 | ORDER BY count DESC LIMIT 12
78 | ''').fetchall()
79 |
80 | # 获取真实统计数据
81 | prompt_count_result = db.execute('SELECT COUNT(*) as count FROM prompts').fetchone()
82 | prompt_count = prompt_count_result['count'] if prompt_count_result else 0
83 |
84 | user_count_result = db.execute('SELECT COUNT(*) as count FROM users').fetchone()
85 | user_count = user_count_result['count'] if user_count_result else 0
86 |
87 | view_count_result = db.execute('SELECT COALESCE(SUM(view_count), 0) as total_views FROM prompts').fetchone()
88 | view_count = view_count_result['total_views'] if view_count_result else 0
89 |
90 | db.commit()
91 | except Exception as e:
92 | from flask import current_app
93 | current_app.logger.error(f"Error fetching data: {e}")
94 |
95 | now = datetime.datetime.now()
96 | return render_template('index.html',
97 | popular_prompts=popular_prompts,
98 | popular_tags=popular_tags,
99 | prompt_count=prompt_count,
100 | user_count=user_count,
101 | view_count=view_count,
102 | now=now)
103 |
104 |
105 | @bp.route('/health')
106 | def health_check():
107 | """健康检查端点,用于容器监控"""
108 | from flask import jsonify
109 | try:
110 | db = get_db()
111 | db.execute('SELECT 1').fetchone()
112 | return jsonify({"status": "healthy", "db_connection": "ok"}), 200
113 | except Exception as e:
114 | from flask import current_app
115 | current_app.logger.error(f"健康检查失败: {str(e)}")
116 | return jsonify({"status": "unhealthy", "error": str(e)}), 500
117 |
118 |
--------------------------------------------------------------------------------
/app/templates/auth/login.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %} {% block title %}登录 - 提示词管理平台{% endblock %}
2 | {% block styles %}
3 |
50 | {% endblock %} {% block content %}
51 |
121 | {% endblock %} {% block scripts %}
122 |
166 | {% endblock %}
167 |
--------------------------------------------------------------------------------
/app/routes/auth.py:
--------------------------------------------------------------------------------
1 | """
2 | 认证相关路由
3 | """
4 | from flask import Blueprint, render_template, redirect, url_for, flash, request, session
5 | from werkzeug.security import generate_password_hash, check_password_hash
6 | from app.database import get_db
7 | from flask import current_app
8 | import datetime
9 |
10 | bp = Blueprint('auth', __name__)
11 |
12 |
13 | @bp.route('/login', methods=['GET', 'POST'])
14 | def login():
15 | """用户登录"""
16 | if 'user_id' in session:
17 | return redirect(url_for('main.index'))
18 |
19 | if request.method == 'POST':
20 | email = request.form['email']
21 | password = request.form['password']
22 |
23 | error = None
24 | db = get_db()
25 |
26 | if not email:
27 | error = '请输入邮箱'
28 | elif not password:
29 | error = '请输入密码'
30 |
31 | if not error:
32 | user = db.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
33 |
34 | if not user:
35 | error = '用户不存在'
36 | elif user['is_banned']:
37 | error = '账号已被禁用,请联系管理员'
38 | else:
39 | try:
40 | if check_password_hash(user['password_hash'], password):
41 | session.clear()
42 | session['user_id'] = user['id']
43 | session['username'] = user['username']
44 | session['is_admin'] = user['is_admin']
45 | if user['avatar_url']:
46 | session['avatar_url'] = user['avatar_url']
47 |
48 | return redirect(url_for('main.index'))
49 | else:
50 | error = '密码错误'
51 | except Exception as e:
52 | current_app.logger.error(f"密码验证过程中出错: {str(e)}")
53 | error = '登录过程中出现错误'
54 |
55 | flash(error, 'danger')
56 |
57 | now = datetime.datetime.now()
58 | return render_template('auth/login.html', now=now)
59 |
60 |
61 | @bp.route('/logout')
62 | def logout():
63 | """用户登出"""
64 | session.clear()
65 | flash('您已成功退出登录', 'success')
66 | return redirect(url_for('main.index'))
67 |
68 |
69 | @bp.route('/register', methods=['GET', 'POST'])
70 | def register():
71 | """用户注册"""
72 | if 'user_id' in session:
73 | return redirect(url_for('main.index'))
74 |
75 | if request.method == 'POST':
76 | username = request.form['username']
77 | email = request.form['email']
78 | password = request.form['password']
79 | password2 = request.form['password2']
80 | invite_code = request.form['invite_code']
81 |
82 | db = get_db()
83 | error = None
84 |
85 | if not username or not email or not password or not password2 or not invite_code:
86 | error = '所有字段都是必填的'
87 | elif password != password2:
88 | error = '两次输入的密码不匹配'
89 | elif len(password) < 8:
90 | error = '密码长度必须至少为8个字符'
91 |
92 | if not error:
93 | if db.execute('SELECT id FROM users WHERE username = ?', (username,)).fetchone():
94 | error = '用户名已被使用'
95 | elif db.execute('SELECT id FROM users WHERE email = ?', (email,)).fetchone():
96 | error = '邮箱已被注册'
97 |
98 | if not error:
99 | invite = db.execute('SELECT * FROM invite_codes WHERE code = ? AND is_used = 0',
100 | (invite_code,)).fetchone()
101 | if not invite:
102 | error = '无效的邀请码'
103 |
104 | if error:
105 | flash(error, 'danger')
106 | else:
107 | is_admin = db.execute('SELECT COUNT(*) as count FROM users').fetchone()['count'] == 0
108 | password_hash = generate_password_hash(password, method='pbkdf2:sha256')
109 |
110 | try:
111 | db.execute(
112 | 'INSERT INTO users (username, email, password_hash, is_admin) VALUES (?, ?, ?, ?)',
113 | (username, email, password_hash, is_admin)
114 | )
115 | user_id = db.execute('SELECT last_insert_rowid()').fetchone()[0]
116 |
117 | db.execute(
118 | 'UPDATE invite_codes SET is_used = 1, used_at = CURRENT_TIMESTAMP, used_by = ? WHERE code = ?',
119 | (user_id, invite_code)
120 | )
121 |
122 | db.commit()
123 | flash('注册成功!现在您可以登录了', 'success')
124 | return redirect(url_for('auth.login'))
125 | except Exception as e:
126 | db.rollback()
127 | current_app.logger.error(f"注册用户时出错: {str(e)}")
128 | flash('注册过程中出现错误', 'danger')
129 |
130 | return render_template('auth/register.html')
131 |
132 |
--------------------------------------------------------------------------------
/app/routes/admin.py:
--------------------------------------------------------------------------------
1 | """
2 | 管理员相关路由
3 | """
4 | from flask import Blueprint, render_template, redirect, url_for, flash, request, session
5 | from app.database import get_db
6 | from app.utils.decorators import admin_required
7 | import random
8 | import string
9 | from flask import current_app
10 |
11 | bp = Blueprint('admin', __name__, url_prefix='/admin')
12 |
13 |
14 | @bp.route('/invite-codes', methods=['GET', 'POST'])
15 | @admin_required
16 | def invite_codes():
17 | """管理邀请码"""
18 | db = get_db()
19 |
20 | if request.method == 'POST':
21 | action = request.form.get('action', '')
22 |
23 | if action == 'generate':
24 | quantity = int(request.form.get('quantity', 1))
25 |
26 | # 生成邀请码
27 | for _ in range(quantity):
28 | code = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(8))
29 | db.execute(
30 | 'INSERT INTO invite_codes (code, creator_id) VALUES (?, ?)',
31 | (code, session['user_id'])
32 | )
33 |
34 | db.commit()
35 | flash(f'成功生成 {quantity} 个邀请码', 'success')
36 |
37 | # 获取所有邀请码
38 | invite_codes = db.execute(
39 | 'SELECT ic.*, u1.username as creator_username, u2.username as used_by_username '
40 | 'FROM invite_codes ic '
41 | 'LEFT JOIN users u1 ON ic.creator_id = u1.id '
42 | 'LEFT JOIN users u2 ON ic.used_by = u2.id '
43 | 'ORDER BY ic.created_at DESC'
44 | ).fetchall()
45 |
46 | return render_template('admin/invite_codes.html', invite_codes=invite_codes)
47 |
48 |
49 | @bp.route('/delete-invite-codes', methods=['POST'])
50 | @admin_required
51 | def delete_invite_codes():
52 | """删除邀请码"""
53 | db = get_db()
54 |
55 | code_ids = request.form.getlist('code_ids')
56 |
57 | if code_ids:
58 | try:
59 | placeholders = ','.join(['?'] * len(code_ids))
60 | query = f'DELETE FROM invite_codes WHERE code IN ({placeholders})'
61 | result = db.execute(query, code_ids)
62 | deleted_count = result.rowcount
63 | db.commit()
64 | flash(f'成功删除 {deleted_count} 个邀请码', 'success')
65 | except Exception as e:
66 | db.rollback()
67 | current_app.logger.error(f"删除邀请码出错: {str(e)}")
68 | flash(f'删除邀请码时出错: {str(e)}', 'danger')
69 | else:
70 | flash('未选择任何邀请码', 'warning')
71 |
72 | return redirect(url_for('admin.invite_codes'))
73 |
74 |
75 | @bp.route('/users')
76 | @admin_required
77 | def users():
78 | """用户管理"""
79 | db = get_db()
80 | users = db.execute(
81 | 'SELECT * FROM users ORDER BY id'
82 | ).fetchall()
83 |
84 | return render_template('admin/users.html', users=users)
85 |
86 |
87 | @bp.route('/users/ban/', methods=['POST'])
88 | @admin_required
89 | def ban_user(id):
90 | """封禁/解封用户"""
91 | db = get_db()
92 |
93 | user = db.execute('SELECT * FROM users WHERE id = ?', (id,)).fetchone()
94 | if not user:
95 | flash('用户不存在', 'danger')
96 | return redirect(url_for('admin.users'))
97 |
98 | if user['is_admin']:
99 | flash('不能封禁管理员账号', 'danger')
100 | return redirect(url_for('admin.users'))
101 |
102 | is_banned = request.form.get('is_banned') == '1'
103 |
104 | # 检查字段是否存在
105 | if 'is_banned' not in [column[1] for column in db.execute('PRAGMA table_info(users)').fetchall()]:
106 | db.execute('ALTER TABLE users ADD COLUMN is_banned BOOLEAN DEFAULT 0')
107 |
108 | db.execute('UPDATE users SET is_banned = ? WHERE id = ?', (is_banned, id))
109 | db.commit()
110 |
111 | if is_banned:
112 | flash(f'用户 {user["username"]} 已被封禁', 'success')
113 | else:
114 | flash(f'用户 {user["username"]} 已被解封', 'success')
115 |
116 | return redirect(url_for('admin.users'))
117 |
118 |
119 | @bp.route('/users/delete/', methods=['POST'])
120 | @admin_required
121 | def delete_user(id):
122 | """删除用户"""
123 | db = get_db()
124 |
125 | user = db.execute('SELECT * FROM users WHERE id = ?', (id,)).fetchone()
126 | if not user:
127 | flash('用户不存在', 'danger')
128 | return redirect(url_for('admin.users'))
129 |
130 | if user['is_admin']:
131 | flash('不能删除管理员账号', 'danger')
132 | return redirect(url_for('admin.users'))
133 |
134 | try:
135 | # 删除用户创建的提示词
136 | prompts = db.execute('SELECT id FROM prompts WHERE user_id = ?', (id,)).fetchall()
137 | for prompt in prompts:
138 | db.execute('DELETE FROM tags_prompts WHERE prompt_id = ?', (prompt['id'],))
139 |
140 | db.execute('DELETE FROM prompts WHERE user_id = ?', (id,))
141 | db.execute('UPDATE invite_codes SET used_by = NULL WHERE used_by = ?', (id,))
142 | db.execute('DELETE FROM users WHERE id = ?', (id,))
143 | db.commit()
144 |
145 | flash(f'用户 {user["username"]} 及其所有内容已被删除', 'success')
146 | except Exception as e:
147 | db.rollback()
148 | current_app.logger.error(f'删除用户时出错: {str(e)}')
149 | flash(f'删除用户时出错: {str(e)}', 'danger')
150 |
151 | return redirect(url_for('admin.users'))
152 |
153 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 提示词管理平台
2 |
3 | 一个用于创建、管理和共享 AI 提示词的平台,支持多用户协作、版本控制和分类管理。
4 |
5 | ## 功能特性
6 |
7 | - **AI 自动填充**:一键生成提示词标题、描述和标签,提升创建效率
8 | - **提示词管理**:创建、编辑、删除和分享 AI 提示词
9 | - **用户系统**:支持用户注册和登录,需要邀请码
10 | - **标签分类**:通过标签组织和分类提示词
11 | - **收藏功能**:收藏常用提示词便于快速访问
12 | - **公开与私有**:设置提示词为公开或私有模式
13 | - **搜索功能**:按标题、描述和标签搜索提示词
14 | - **响应式设计**:适配电脑、平板和手机等各种设备屏幕
15 | - **管理员面板**:管理用户和邀请码
16 |
17 | ## 技术栈
18 |
19 | - **后端**:Flask (Python)
20 | - **前端**:Bootstrap 5, JavaScript
21 | - **数据库**:SQLite (可扩展到其他数据库)
22 | - **AI 集成**:OpenAI API、自定义 API(兼容 OpenAI 格式)
23 |
24 | ## 本地开发环境设置
25 |
26 | ### 前提条件
27 |
28 | - Python 3.6+
29 | - pip (Python 包管理器)
30 |
31 | ### 安装步骤
32 |
33 | 1. 克隆仓库:
34 |
35 | ```bash
36 | git clone https://github.com/DEKVIW/prompt-manager.git
37 | cd prompt-manager
38 | ```
39 |
40 | 2. 创建并激活虚拟环境:
41 |
42 | ```bash
43 | # 在Linux/macOS上
44 | python -m venv venv
45 | source venv/bin/activate
46 |
47 | # 在Windows上
48 | python -m venv venv
49 | venv\Scripts\activate
50 | ```
51 |
52 | 3. 安装依赖:
53 |
54 | ```bash
55 | pip install -r requirements.txt
56 | ```
57 |
58 | 4. 初始化数据库:
59 |
60 | ```bash
61 | python init_db.py
62 | ```
63 |
64 | 5. 启动应用:
65 |
66 | ```bash
67 | python run.py
68 | ```
69 |
70 | 6. 访问应用:
71 | 打开浏览器访问 `http://127.0.0.1:5000`
72 |
73 | 7. 配置 AI 自动填充(可选):
74 | - 登录后进入"个人资料" → "AI 设置"
75 | - 配置您的 OpenAI API Key 或自定义 API
76 | - 保存后即可在创建提示词时使用 AI 自动填充功能
77 |
78 | ## Docker 部署
79 |
80 | ### 使用 Docker Compose(推荐)
81 |
82 | #### 1. 配置环境变量
83 |
84 | 创建 `.env` 文件(可选,如不创建将使用默认值):
85 |
86 | ```env
87 | SECRET_KEY=your-secret-key-here-change-this
88 | FLASK_DEBUG=false
89 | ```
90 |
91 | #### 2. 构建并启动
92 |
93 | ```bash
94 | docker-compose up -d
95 | ```
96 |
97 | #### 3. 查看日志
98 |
99 | ```bash
100 | docker-compose logs -f
101 | ```
102 |
103 | #### 4. 停止服务
104 |
105 | ```bash
106 | docker-compose down
107 | ```
108 |
109 | #### 5. 访问应用
110 |
111 | 打开浏览器访问 `http://localhost:5000`
112 |
113 | #### 6. 配置 AI 自动填充(可选)
114 |
115 | - 登录后进入"个人资料" → "AI 设置"
116 | - 配置您的 OpenAI API Key 或自定义 API
117 | - 保存后即可在创建提示词时使用 AI 自动填充功能
118 |
119 | ### 手动 Docker 部署
120 |
121 | #### 1. 构建镜像
122 |
123 | ```bash
124 | docker build -t prompt-manager:latest .
125 | ```
126 |
127 | #### 2. 运行容器
128 |
129 | ```bash
130 | docker run -d \
131 | --name prompt-manager \
132 | -p 5000:80 \
133 | -v $(pwd)/instance:/app/instance \
134 | -v $(pwd)/logs:/app/logs \
135 | -v $(pwd)/uploads:/app/app/static/img/avatars \
136 | -e SECRET_KEY=your-secret-key-here \
137 | prompt-manager:latest
138 | ```
139 |
140 | ### Docker 配置说明
141 |
142 | - **端口映射**:容器内部 80 端口映射到主机 5000 端口
143 | - **数据持久化**:
144 | - `./instance` → `/app/instance` (数据库文件)
145 | - `./logs` → `/app/logs` (日志文件)
146 | - `./uploads` → `/app/app/static/img/avatars` (上传的头像)
147 | - **健康检查**:容器包含健康检查端点 `/health`
148 |
149 | ## 项目结构
150 |
151 | ```
152 | prompt-manager/
153 | ├── app/ # 应用主包
154 | │ ├── __init__.py # 应用工厂函数
155 | │ ├── config.py # 配置文件
156 | │ ├── extensions.py # 扩展初始化
157 | │ ├── database/ # 数据库层
158 | │ │ └── db.py # 数据库连接和操作
159 | │ ├── routes/ # 路由层(蓝图)
160 | │ │ ├── __init__.py # 蓝图注册
161 | │ │ ├── main.py # 主页面路由
162 | │ │ ├── auth.py # 认证路由
163 | │ │ ├── prompts.py # 提示词路由
164 | │ │ ├── admin.py # 管理路由
165 | │ │ ├── user.py # 用户路由
166 | │ │ └── ai.py # AI 相关路由
167 | │ ├── services/ # 业务逻辑层
168 | │ │ ├── __init__.py
169 | │ │ ├── prompt_service.py
170 | │ │ ├── user_service.py
171 | │ │ ├── admin_service.py
172 | │ │ ├── tag_service.py
173 | │ │ └── ai_service.py # AI 服务
174 | │ ├── services/ai/ # AI 客户端
175 | │ │ ├── base_client.py
176 | │ │ ├── openai_client.py
177 | │ │ ├── custom_client.py
178 | │ │ └── factory.py
179 | │ ├── utils/ # 工具函数
180 | │ │ ├── __init__.py
181 | │ │ ├── decorators.py
182 | │ │ ├── helpers.py
183 | │ │ ├── file_upload.py
184 | │ │ └── encryption.py # 加密工具(用于 API Key 加密)
185 | │ ├── templates/ # HTML模板
186 | │ └── static/ # 静态资源(CSS, JS, 图片)
187 | ├── instance/ # 实例数据(SQLite数据库,自动创建)
188 | ├── logs/ # 日志文件(自动创建)
189 | ├── requirements.txt # Python依赖
190 | ├── init_db.py # 数据库初始化脚本
191 | ├── migrations/ # 数据库迁移脚本
192 | │ └── add_ai_configs_table.py
193 | ├── run.py # 开发环境运行入口
194 | ├── wsgi.py # WSGI入口(生产环境)
195 | ├── Dockerfile # Docker 镜像构建文件
196 | ├── docker-compose.yml # Docker Compose 配置
197 | ├── docker-entrypoint.sh # Docker 容器启动脚本
198 | ├── nginx.conf # Nginx 配置文件
199 | └── README.md # 项目说明
200 | ```
201 |
202 | ## 默认账户
203 |
204 | - **管理员账户**:
205 | - 邮箱: admin@example.com
206 | - 密码: admin123
207 |
208 | **重要**: 首次登录后请立即修改默认密码!
209 |
210 | ## AI 自动填充使用说明
211 |
212 | ### 配置步骤
213 |
214 | 1. **获取 API Key**
215 |
216 | - OpenAI: 访问 [OpenAI Platform](https://platform.openai.com/api-keys) 获取 API Key
217 | - 自定义 API: 使用兼容 OpenAI 格式的 API 服务
218 |
219 | 2. **配置 API**
220 |
221 | - 登录后,点击右上角用户头像 → "编辑个人资料"
222 | - 切换到 "AI 设置" 标签页
223 | - 选择 AI 提供商(OpenAI 或自定义 API)
224 | - 输入 API Key 和模型名称
225 | - 可选:配置基础 URL(自定义 API 需要)
226 | - 点击"测试连接"验证配置是否正确
227 | - 保存设置
228 |
229 | 3. **使用自动填充**
230 | - 创建新提示词时,输入提示词内容
231 | - 点击"AI 自动填充"按钮
232 | - 系统将自动生成标题、描述和标签
233 | - 可根据需要调整生成的内容
234 |
235 | ### 支持的 AI 提供商
236 |
237 | - **OpenAI**: GPT-3.5-turbo, GPT-4, GPT-4-turbo 等
238 | - **自定义 API**: 任何兼容 OpenAI 格式的 API 服务
239 |
240 | ### 安全说明
241 |
242 | - API Key 采用加密存储,确保安全性
243 | - 支持 API Key 更新,无需重新输入即可保留原配置
244 |
245 | ## 贡献指南
246 |
247 | 欢迎贡献代码、报告问题或提出功能建议!请遵循以下步骤:
248 |
249 | 1. Fork 仓库
250 | 2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
251 | 3. 提交更改 (`git commit -m 'Add amazing feature'`)
252 | 4. 推送到分支 (`git push origin feature/amazing-feature`)
253 | 5. 创建 Pull Request
254 |
255 | ## 许可证
256 |
257 | 本项目采用 MIT 许可证 - 详情参见 [LICENSE](LICENSE) 文件
258 |
--------------------------------------------------------------------------------
/app/services/user_service.py:
--------------------------------------------------------------------------------
1 | """
2 | 用户业务逻辑
3 | """
4 | from werkzeug.security import generate_password_hash, check_password_hash
5 | from app.database import get_db
6 | import os
7 | import uuid
8 | from werkzeug.utils import secure_filename
9 |
10 |
11 | def authenticate_user(email, password):
12 | """验证用户登录"""
13 | db = get_db()
14 | user = db.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
15 |
16 | if not user:
17 | return None, '用户不存在'
18 |
19 | if user['is_banned']:
20 | return None, '账号已被禁用,请联系管理员'
21 |
22 | try:
23 | if check_password_hash(user['password_hash'], password):
24 | return dict(user), None
25 | else:
26 | return None, '密码错误'
27 | except Exception as e:
28 | return None, f'登录过程中出现错误: {str(e)}'
29 |
30 |
31 | def register_user(username, email, password, invite_code):
32 | """注册新用户"""
33 | db = get_db()
34 |
35 | # 检查用户名和邮箱是否已存在
36 | if db.execute('SELECT id FROM users WHERE username = ?', (username,)).fetchone():
37 | return None, '用户名已被使用'
38 |
39 | if db.execute('SELECT id FROM users WHERE email = ?', (email,)).fetchone():
40 | return None, '邮箱已被注册'
41 |
42 | # 检查邀请码
43 | invite = db.execute('SELECT * FROM invite_codes WHERE code = ? AND is_used = 0',
44 | (invite_code,)).fetchone()
45 | if not invite:
46 | return None, '无效的邀请码'
47 |
48 | # 检查是否是第一个用户(自动设为管理员)
49 | is_admin = db.execute('SELECT COUNT(*) as count FROM users').fetchone()['count'] == 0
50 | password_hash = generate_password_hash(password, method='pbkdf2:sha256')
51 |
52 | try:
53 | db.execute(
54 | 'INSERT INTO users (username, email, password_hash, is_admin) VALUES (?, ?, ?, ?)',
55 | (username, email, password_hash, is_admin)
56 | )
57 | user_id = db.execute('SELECT last_insert_rowid()').fetchone()[0]
58 |
59 | # 标记邀请码为已使用
60 | db.execute(
61 | 'UPDATE invite_codes SET is_used = 1, used_at = CURRENT_TIMESTAMP, used_by = ? WHERE code = ?',
62 | (user_id, invite_code)
63 | )
64 |
65 | db.commit()
66 | return user_id, None
67 | except Exception as e:
68 | db.rollback()
69 | return None, f'注册过程中出现错误: {str(e)}'
70 |
71 |
72 | def get_user_by_id(user_id):
73 | """根据ID获取用户信息"""
74 | db = get_db()
75 | user = db.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
76 | return dict(user) if user else None
77 |
78 |
79 | def get_user_profile(user_id):
80 | """获取用户资料(包含统计信息)"""
81 | db = get_db()
82 | user = db.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
83 |
84 | if not user:
85 | return None
86 |
87 | user = dict(user)
88 |
89 | # 获取统计信息
90 | user['prompt_count'] = db.execute(
91 | 'SELECT COUNT(*) as count FROM prompts WHERE user_id = ?',
92 | (user_id,)
93 | ).fetchone()['count']
94 |
95 | user['total_views'] = db.execute(
96 | 'SELECT SUM(view_count) as total FROM prompts WHERE user_id = ?',
97 | (user_id,)
98 | ).fetchone()['total'] or 0
99 |
100 | return user
101 |
102 |
103 | def update_user_profile(user_id, username=None, bio=None, avatar_file=None):
104 | """更新用户资料"""
105 | db = get_db()
106 |
107 | # 检查用户名是否已被其他用户使用
108 | if username:
109 | existing = db.execute(
110 | 'SELECT id FROM users WHERE username = ? AND id != ?',
111 | (username, user_id)
112 | ).fetchone()
113 | if existing:
114 | return False, '用户名已被使用'
115 |
116 | # 更新基本信息
117 | updates = []
118 | params = []
119 |
120 | if username:
121 | updates.append('username = ?')
122 | params.append(username)
123 |
124 | if bio is not None:
125 | updates.append('bio = ?')
126 | params.append(bio)
127 |
128 | # 处理头像上传
129 | if avatar_file and avatar_file.filename:
130 | avatar_url = save_avatar(avatar_file, user_id)
131 | if avatar_url:
132 | updates.append('avatar_url = ?')
133 | params.append(avatar_url)
134 |
135 | if updates:
136 | params.append(user_id)
137 | query = f"UPDATE users SET {', '.join(updates)} WHERE id = ?"
138 | db.execute(query, params)
139 | db.commit()
140 |
141 | return True, None
142 |
143 |
144 | def save_avatar(file, user_id):
145 | """保存用户头像"""
146 | from flask import current_app
147 |
148 | if not file or not file.filename:
149 | return None
150 |
151 | # 检查文件扩展名
152 | allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
153 | filename = secure_filename(file.filename)
154 | if '.' not in filename or filename.rsplit('.', 1)[1].lower() not in allowed_extensions:
155 | return None
156 |
157 | # 生成唯一文件名
158 | file_ext = filename.rsplit('.', 1)[1].lower()
159 | unique_filename = f"{uuid.uuid4().hex}.{file_ext}"
160 |
161 | # 使用配置中的上传目录
162 | avatars_dir = current_app.config.get('UPLOAD_FOLDER')
163 | if not avatars_dir:
164 | avatars_dir = os.path.join('app', 'static', 'img', 'avatars')
165 |
166 | # 确保目录存在
167 | if not os.path.exists(avatars_dir):
168 | os.makedirs(avatars_dir, exist_ok=True)
169 |
170 | # 保存文件
171 | file_path = os.path.join(avatars_dir, unique_filename)
172 | file.save(file_path)
173 |
174 | # 返回相对路径(用于URL)
175 | return f"static/img/avatars/{unique_filename}"
176 |
177 |
178 | def change_password(user_id, old_password, new_password):
179 | """修改密码"""
180 | db = get_db()
181 | user = db.execute('SELECT password_hash FROM users WHERE id = ?', (user_id,)).fetchone()
182 |
183 | if not user:
184 | return False, '用户不存在'
185 |
186 | # 验证旧密码
187 | if not check_password_hash(user['password_hash'], old_password):
188 | return False, '原密码错误'
189 |
190 | # 更新密码
191 | new_password_hash = generate_password_hash(new_password, method='pbkdf2:sha256')
192 | db.execute('UPDATE users SET password_hash = ? WHERE id = ?', (new_password_hash, user_id))
193 | db.commit()
194 |
195 | return True, None
196 |
197 |
--------------------------------------------------------------------------------
/init_db.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sqlite3
3 | import random
4 | import string
5 | import sys
6 | from werkzeug.security import generate_password_hash
7 |
8 | def generate_random_code(length=8):
9 | """生成随机邀请码"""
10 | chars = string.ascii_uppercase + string.ascii_lowercase + string.digits
11 | return ''.join(random.choice(chars) for _ in range(length))
12 |
13 | def init_db():
14 | print("开始初始化数据库...")
15 |
16 | # 创建数据库目录
17 | db_path = "instance"
18 | print(f"创建数据库目录: {db_path}")
19 | if not os.path.exists(db_path):
20 | os.makedirs(db_path)
21 | print(f"目录已创建")
22 | else:
23 | print(f"目录已存在")
24 |
25 | db_file = os.path.join(db_path, "prompts.db")
26 | print(f"数据库文件路径: {db_file}")
27 |
28 | # 如果数据库已存在,先询问是否删除
29 | db_exists = os.path.exists(db_file)
30 | if db_exists:
31 | print(f"数据库文件已存在。跳过初始化过程。")
32 | return True
33 |
34 | print("创建新数据库...")
35 |
36 | try:
37 | # 创建数据库连接
38 | conn = sqlite3.connect(db_file)
39 | cursor = conn.cursor()
40 |
41 | print("创建表...")
42 |
43 | # 创建用户表
44 | cursor.execute('''
45 | CREATE TABLE users (
46 | id INTEGER PRIMARY KEY AUTOINCREMENT,
47 | username TEXT UNIQUE NOT NULL,
48 | email TEXT UNIQUE NOT NULL,
49 | password_hash TEXT NOT NULL,
50 | is_admin BOOLEAN DEFAULT 0,
51 | is_banned BOOLEAN DEFAULT 0,
52 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
53 | )
54 | ''')
55 |
56 | # 创建邀请码表
57 | cursor.execute('''
58 | CREATE TABLE invite_codes (
59 | id INTEGER PRIMARY KEY AUTOINCREMENT,
60 | code TEXT UNIQUE NOT NULL,
61 | is_used BOOLEAN DEFAULT 0,
62 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
63 | used_at TIMESTAMP,
64 | creator_id INTEGER,
65 | used_by INTEGER,
66 | FOREIGN KEY (creator_id) REFERENCES users (id),
67 | FOREIGN KEY (used_by) REFERENCES users (id)
68 | )
69 | ''')
70 |
71 | # 创建标签表
72 | cursor.execute('''
73 | CREATE TABLE tags (
74 | id INTEGER PRIMARY KEY AUTOINCREMENT,
75 | name TEXT UNIQUE NOT NULL,
76 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
77 | )
78 | ''')
79 |
80 | # 创建提示词表
81 | cursor.execute('''
82 | CREATE TABLE prompts (
83 | id INTEGER PRIMARY KEY AUTOINCREMENT,
84 | title TEXT NOT NULL,
85 | content TEXT NOT NULL,
86 | description TEXT,
87 | version TEXT,
88 | cover_image TEXT,
89 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
90 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
91 | user_id INTEGER NOT NULL,
92 | is_public BOOLEAN DEFAULT 0,
93 | view_count INTEGER DEFAULT 0,
94 | share_count INTEGER DEFAULT 0,
95 | _metadata TEXT,
96 | FOREIGN KEY (user_id) REFERENCES users (id)
97 | )
98 | ''')
99 |
100 | # 创建标签-提示词关联表
101 | cursor.execute('''
102 | CREATE TABLE tags_prompts (
103 | tag_id INTEGER NOT NULL,
104 | prompt_id INTEGER NOT NULL,
105 | PRIMARY KEY (tag_id, prompt_id),
106 | FOREIGN KEY (tag_id) REFERENCES tags (id),
107 | FOREIGN KEY (prompt_id) REFERENCES prompts (id)
108 | )
109 | ''')
110 |
111 | # 创建 AI 配置表
112 | cursor.execute('''
113 | CREATE TABLE ai_configs (
114 | id INTEGER PRIMARY KEY AUTOINCREMENT,
115 | user_id INTEGER NOT NULL UNIQUE,
116 | provider TEXT NOT NULL DEFAULT 'openai',
117 | api_key TEXT NOT NULL,
118 | base_url TEXT,
119 | model TEXT DEFAULT 'gpt-3.5-turbo',
120 | temperature REAL DEFAULT 0.7,
121 | max_tokens INTEGER DEFAULT 500,
122 | enabled BOOLEAN DEFAULT 1,
123 | title_max_length INTEGER DEFAULT 30,
124 | description_max_length INTEGER DEFAULT 100,
125 | tag_count INTEGER DEFAULT 5,
126 | tag_two_char_count INTEGER DEFAULT 0,
127 | tag_four_char_count INTEGER DEFAULT 0,
128 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
129 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
130 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
131 | )
132 | ''')
133 |
134 | # 创建索引
135 | cursor.execute('''
136 | CREATE INDEX idx_ai_configs_user_id ON ai_configs(user_id)
137 | ''')
138 |
139 | print("创建管理员账号...")
140 |
141 | # 创建管理员账号,使用 werkzeug.security 生成密码哈希
142 | admin_password = 'admin123'
143 | admin_password_hash = generate_password_hash(admin_password, method='pbkdf2:sha256')
144 | cursor.execute('''
145 | INSERT INTO users (username, email, password_hash, is_admin)
146 | VALUES (?, ?, ?, ?)
147 | ''', ('admin', 'admin@example.com', admin_password_hash, 1))
148 |
149 | # 获取管理员ID
150 | admin_id = cursor.lastrowid
151 |
152 | print("生成邀请码...")
153 |
154 | # 创建邀请码
155 | invite_codes = []
156 | for _ in range(5):
157 | code = generate_random_code()
158 | invite_codes.append(code)
159 | cursor.execute('''
160 | INSERT INTO invite_codes (code, creator_id)
161 | VALUES (?, ?)
162 | ''', (code, admin_id))
163 |
164 | # 提交事务
165 | conn.commit()
166 |
167 | # 关闭连接
168 | conn.close()
169 |
170 | print("\n数据库初始化成功!")
171 | print("管理员账号: admin@example.com")
172 | print("密码: admin123")
173 | print("已生成以下邀请码:")
174 | for i, code in enumerate(invite_codes, 1):
175 | print(f"邀请码 {i}: {code}")
176 |
177 | return True
178 | except Exception as e:
179 | print(f"初始化数据库时出错: {e}")
180 | import traceback
181 | traceback.print_exc()
182 | return False
183 |
184 | if __name__ == "__main__":
185 | success = init_db()
186 | sys.exit(0 if success else 1)
--------------------------------------------------------------------------------
/app/routes/ai.py:
--------------------------------------------------------------------------------
1 | """
2 | AI 相关路由
3 | 提供 AI 自动填充功能的 API 接口
4 | """
5 | from flask import Blueprint, request, jsonify, session
6 | from app.database import get_db
7 | from app.utils.decorators import login_required
8 | from app.utils.encryption import decrypt_string
9 | from app.services.ai.factory import AIClientFactory
10 | from app.services.ai_service import AIService
11 | from flask import current_app
12 |
13 | bp = Blueprint('ai', __name__, url_prefix='/api/ai')
14 |
15 |
16 | @bp.route('/generate-metadata', methods=['POST'])
17 | @login_required
18 | def generate_metadata():
19 | """
20 | 生成提示词元数据
21 |
22 | 请求体:
23 | {
24 | "content": "提示词内容",
25 | "title_max_length": 30, // 可选,默认从用户配置读取
26 | "description_max_length": 100, // 可选,默认从用户配置读取
27 | "tag_count": 5, // 可选,默认从用户配置读取
28 | "tag_two_char_count": 0, // 可选,默认从用户配置读取
29 | "tag_four_char_count": 0 // 可选,默认从用户配置读取
30 | }
31 |
32 | 返回:
33 | {
34 | "success": true,
35 | "title": "标题",
36 | "description": "描述",
37 | "tags": ["标签1", "标签2"]
38 | }
39 | """
40 | user_id = session.get('user_id')
41 | if not user_id:
42 | return jsonify({'success': False, 'message': '未登录'}), 401
43 |
44 | try:
45 | data = request.get_json()
46 | if not data:
47 | return jsonify({'success': False, 'message': '请求体不能为空'}), 400
48 |
49 | content = data.get('content', '').strip()
50 | if not content:
51 | return jsonify({'success': False, 'message': '提示词内容不能为空'}), 400
52 |
53 | # 获取用户 AI 配置
54 | db = get_db()
55 | config = db.execute(
56 | 'SELECT * FROM ai_configs WHERE user_id = ? AND enabled = 1',
57 | (user_id,)
58 | ).fetchone()
59 |
60 | if not config:
61 | return jsonify({
62 | 'success': False,
63 | 'message': '未配置 AI API,请先前往设置页面配置'
64 | }), 400
65 |
66 | # 将 Row 对象转换为字典
67 | config = dict(config)
68 |
69 | # 解密 API Key
70 | try:
71 | api_key = decrypt_string(config['api_key'])
72 | except Exception as e:
73 | current_app.logger.error(f'解密 API Key 失败: {str(e)}')
74 | return jsonify({
75 | 'success': False,
76 | 'message': 'API Key 解密失败,请重新配置'
77 | }), 500
78 |
79 | # 创建 AI 客户端
80 | client = AIClientFactory.create_client(
81 | provider=config['provider'],
82 | api_key=api_key,
83 | base_url=config.get('base_url'),
84 | model=config.get('model', 'gpt-3.5-turbo'),
85 | temperature=config.get('temperature', 0.7),
86 | max_tokens=config.get('max_tokens', 500)
87 | )
88 |
89 | # 创建 AI 服务
90 | ai_service = AIService(client)
91 |
92 | # 生成元数据(使用固定规则,不再接受参数)
93 | metadata = ai_service.generate_metadata(prompt_content=content)
94 |
95 | return jsonify({
96 | 'success': True,
97 | 'title': metadata['title'],
98 | 'description': metadata['description'],
99 | 'tags': metadata['tags']
100 | })
101 |
102 | except ValueError as e:
103 | return jsonify({'success': False, 'message': str(e)}), 400
104 | except Exception as e:
105 | current_app.logger.error(f'生成元数据失败: {str(e)}')
106 | return jsonify({
107 | 'success': False,
108 | 'message': f'生成失败: {str(e)}'
109 | }), 500
110 |
111 |
112 | @bp.route('/test-connection', methods=['POST'])
113 | @login_required
114 | def test_connection():
115 | """
116 | 测试 AI API 连接
117 |
118 | 请求体:
119 | {
120 | "provider": "openai", // 可选,如果为空则使用已保存的配置
121 | "api_key": "sk-...", // 可选,如果为空则使用已保存的 key
122 | "base_url": "https://...", // 可选
123 | "model": "gpt-3.5-turbo" // 可选
124 | }
125 |
126 | 返回:
127 | {
128 | "success": true/false,
129 | "message": "连接成功" / "连接失败: 原因"
130 | }
131 | """
132 | user_id = session.get('user_id')
133 | if not user_id:
134 | return jsonify({'success': False, 'message': '未登录'}), 401
135 |
136 | try:
137 | data = request.get_json() or {}
138 |
139 | # 获取用户已保存的配置
140 | db = get_db()
141 | config = db.execute(
142 | 'SELECT * FROM ai_configs WHERE user_id = ? AND enabled = 1',
143 | (user_id,)
144 | ).fetchone()
145 |
146 | # 如果输入框提供了新的 key,使用新的;否则使用已保存的
147 | api_key_input = data.get('api_key', '').strip()
148 |
149 | if api_key_input:
150 | # 使用输入框中的新 key
151 | api_key = api_key_input
152 | provider = data.get('provider', 'openai')
153 | base_url = data.get('base_url', '').strip() or None
154 | model = data.get('model', 'gpt-3.5-turbo')
155 | elif config:
156 | # 使用已保存的配置
157 | config = dict(config)
158 | try:
159 | api_key = decrypt_string(config['api_key'])
160 | except Exception as e:
161 | current_app.logger.error(f'解密 API Key 失败: {str(e)}')
162 | return jsonify({
163 | 'success': False,
164 | 'message': 'API Key 解密失败,请重新配置'
165 | }), 500
166 |
167 | provider = data.get('provider') or config.get('provider', 'openai')
168 | base_url = data.get('base_url', '').strip() or config.get('base_url')
169 | model = data.get('model') or config.get('model', 'gpt-3.5-turbo')
170 | else:
171 | # 既没有输入,也没有保存的配置
172 | return jsonify({
173 | 'success': False,
174 | 'message': '请先输入 API Key 或保存配置'
175 | }), 400
176 |
177 | if not api_key:
178 | return jsonify({'success': False, 'message': 'API Key 不能为空'}), 400
179 |
180 | # 创建 AI 客户端
181 | try:
182 | client = AIClientFactory.create_client(
183 | provider=provider,
184 | api_key=api_key,
185 | base_url=base_url,
186 | model=model,
187 | temperature=0.7,
188 | max_tokens=100
189 | )
190 | except ValueError as e:
191 | return jsonify({'success': False, 'message': str(e)}), 400
192 |
193 | # 测试连接
194 | if client.test_connection():
195 | return jsonify({
196 | 'success': True,
197 | 'message': '连接成功'
198 | })
199 | else:
200 | return jsonify({
201 | 'success': False,
202 | 'message': '连接失败,请检查 API Key 和配置'
203 | })
204 |
205 | except Exception as e:
206 | current_app.logger.error(f'测试连接失败: {str(e)}')
207 | return jsonify({
208 | 'success': False,
209 | 'message': f'测试失败: {str(e)}'
210 | }), 500
211 |
212 |
--------------------------------------------------------------------------------
/app/templates/auth/register.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %} {% block title %}注册 - 提示词管理平台{% endblock %}
2 | {% block styles %}
3 |
57 | {% endblock %} {% block content %}
58 |
163 | {% endblock %} {% block scripts %}
164 |
235 | {% endblock %}
236 |
--------------------------------------------------------------------------------
/app/templates/admin/users.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %} {% block title %}用户管理 - 提示词管理平台{% endblock
2 | %} {% block styles %}
3 |
173 | {% endblock %} {% block content %}
174 |
175 |
176 |
177 |
178 |
用户管理
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
用户列表
189 |
← 左右滑动查看更多 →
190 |
277 |
278 |
279 |
280 |
281 |
282 | {% endblock %}
283 |
--------------------------------------------------------------------------------
/app/services/prompt_service.py:
--------------------------------------------------------------------------------
1 | """
2 | 提示词业务逻辑
3 | """
4 | from app.database import get_db
5 | from app.services.tag_service import link_tags_to_prompt
6 | from app.utils.helpers import format_datetime
7 |
8 |
9 | def create_prompt(user_id, title, content, description, version, is_public, tag_names):
10 | """创建提示词"""
11 | db = get_db()
12 |
13 | # 插入提示词
14 | db.execute(
15 | 'INSERT INTO prompts (title, content, description, version, user_id, is_public) VALUES (?, ?, ?, ?, ?, ?)',
16 | (title, content, description, version, user_id, is_public)
17 | )
18 | prompt_id = db.execute('SELECT last_insert_rowid()').fetchone()[0]
19 |
20 | # 处理标签
21 | link_tags_to_prompt(db, prompt_id, tag_names)
22 |
23 | db.commit()
24 | return prompt_id
25 |
26 |
27 | def update_prompt(prompt_id, user_id, title, content, description, version, is_public, tag_names):
28 | """更新提示词"""
29 | db = get_db()
30 |
31 | # 检查权限
32 | prompt = db.execute('SELECT user_id FROM prompts WHERE id = ?', (prompt_id,)).fetchone()
33 | if not prompt or prompt['user_id'] != user_id:
34 | raise PermissionError('您没有权限编辑此提示词')
35 |
36 | # 更新提示词
37 | db.execute(
38 | 'UPDATE prompts SET title = ?, content = ?, description = ?, version = ?, is_public = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
39 | (title, content, description, version, is_public, prompt_id)
40 | )
41 |
42 | # 删除旧标签关联
43 | db.execute('DELETE FROM tags_prompts WHERE prompt_id = ?', (prompt_id,))
44 |
45 | # 添加新标签
46 | link_tags_to_prompt(db, prompt_id, tag_names)
47 |
48 | db.commit()
49 |
50 |
51 | def delete_prompt(prompt_id, user_id, is_admin=False):
52 | """删除提示词"""
53 | db = get_db()
54 |
55 | prompt = db.execute('SELECT * FROM prompts WHERE id = ?', (prompt_id,)).fetchone()
56 | if not prompt:
57 | raise ValueError('提示词不存在')
58 |
59 | # 检查权限
60 | if not is_admin and prompt['user_id'] != user_id:
61 | raise PermissionError('您没有权限删除此提示词')
62 |
63 | # 删除标签关联
64 | db.execute('DELETE FROM tags_prompts WHERE prompt_id = ?', (prompt_id,))
65 |
66 | # 删除收藏关联
67 | db.execute('DELETE FROM favorites WHERE prompt_id = ?', (prompt_id,))
68 |
69 | # 删除提示词
70 | db.execute('DELETE FROM prompts WHERE id = ?', (prompt_id,))
71 |
72 | db.commit()
73 |
74 |
75 | def get_prompt_by_id(prompt_id, user_id=None):
76 | """根据ID获取提示词"""
77 | db = get_db()
78 | prompt = db.execute(
79 | 'SELECT p.*, u.username, u.avatar_url FROM prompts p JOIN users u ON p.user_id = u.id WHERE p.id = ?',
80 | (prompt_id,)
81 | ).fetchone()
82 |
83 | if not prompt:
84 | return None
85 |
86 | prompt = dict(prompt)
87 |
88 | # 格式化日期时间
89 | if 'created_at' in prompt and prompt['created_at']:
90 | prompt['created_at'] = format_datetime(prompt['created_at'])
91 | if 'updated_at' in prompt and prompt['updated_at']:
92 | prompt['updated_at'] = format_datetime(prompt['updated_at'])
93 |
94 | # 检查权限
95 | if not prompt['is_public'] and (not user_id or user_id != prompt['user_id']):
96 | return None
97 |
98 | # 获取标签
99 | tags = db.execute(
100 | 'SELECT t.* FROM tags t JOIN tags_prompts tp ON t.id = tp.tag_id WHERE tp.prompt_id = ?',
101 | (prompt_id,)
102 | ).fetchall()
103 | prompt['tags'] = tags
104 |
105 | # 更新浏览计数
106 | db.execute('UPDATE prompts SET view_count = view_count + 1 WHERE id = ?', (prompt_id,))
107 | db.commit()
108 |
109 | return prompt
110 |
111 |
112 | def get_user_prompts(user_id, page=1, per_page=9):
113 | """获取用户的提示词列表"""
114 | db = get_db()
115 |
116 | # 计算总数
117 | total_count = db.execute(
118 | 'SELECT COUNT(*) as count FROM prompts WHERE user_id = ?',
119 | (user_id,)
120 | ).fetchone()['count']
121 |
122 | # 计算总页数
123 | total_pages = (total_count + per_page - 1) // per_page
124 |
125 | # 确保page在有效范围内
126 | if page < 1:
127 | page = 1
128 | elif page > total_pages and total_pages > 0:
129 | page = total_pages
130 |
131 | # 获取提示词数据
132 | prompt_rows = db.execute(
133 | 'SELECT p.*, u.username FROM prompts p JOIN users u ON p.user_id = u.id WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?',
134 | (user_id, per_page, (page - 1) * per_page)
135 | ).fetchall()
136 |
137 | prompts = [dict(row) for row in prompt_rows]
138 |
139 | # 获取所有提示词的标签
140 | for prompt in prompts:
141 | tags = db.execute(
142 | 'SELECT t.* FROM tags t '
143 | 'JOIN tags_prompts tp ON t.id = tp.tag_id '
144 | 'WHERE tp.prompt_id = ?',
145 | (prompt['id'],)
146 | ).fetchall()
147 | prompt['tags'] = tags
148 |
149 | # 格式化日期时间
150 | if 'created_at' in prompt and prompt['created_at']:
151 | prompt['created_at'] = format_datetime(prompt['created_at'])
152 | if 'updated_at' in prompt and prompt['updated_at']:
153 | prompt['updated_at'] = format_datetime(prompt['updated_at'])
154 |
155 | return {
156 | 'prompts': prompts,
157 | 'current_page': page,
158 | 'total_pages': total_pages,
159 | 'total_count': total_count
160 | }
161 |
162 |
163 | def get_public_prompts(page=1, per_page=9, search_query=None, tag_filter=None):
164 | """获取公开提示词列表"""
165 | db = get_db()
166 |
167 | # 构建查询
168 | query = 'SELECT p.*, u.username, u.avatar_url FROM prompts p JOIN users u ON p.user_id = u.id WHERE p.is_public = 1'
169 | params = []
170 |
171 | if search_query:
172 | query += ' AND (p.title LIKE ? OR p.description LIKE ?)'
173 | search_pattern = f'%{search_query}%'
174 | params.extend([search_pattern, search_pattern])
175 |
176 | if tag_filter:
177 | query += ' AND p.id IN (SELECT tp.prompt_id FROM tags_prompts tp JOIN tags t ON tp.tag_id = t.id WHERE t.name = ?)'
178 | params.append(tag_filter)
179 |
180 | # 计算总数
181 | count_query = query.replace('SELECT p.*, u.username, u.avatar_url', 'SELECT COUNT(*) as count')
182 | total_count = db.execute(count_query, params).fetchone()['count']
183 |
184 | # 计算总页数
185 | total_pages = (total_count + per_page - 1) // per_page
186 |
187 | # 确保page在有效范围内
188 | if page < 1:
189 | page = 1
190 | elif page > total_pages and total_pages > 0:
191 | page = total_pages
192 |
193 | # 添加排序和分页
194 | query += ' ORDER BY p.view_count DESC, p.created_at DESC LIMIT ? OFFSET ?'
195 | params.extend([per_page, (page - 1) * per_page])
196 |
197 | # 获取提示词数据
198 | prompt_rows = db.execute(query, params).fetchall()
199 |
200 | prompts = [dict(row) for row in prompt_rows]
201 |
202 | # 获取所有提示词的标签
203 | for prompt in prompts:
204 | tags = db.execute(
205 | 'SELECT t.* FROM tags t '
206 | 'JOIN tags_prompts tp ON t.id = tp.tag_id '
207 | 'WHERE tp.prompt_id = ?',
208 | (prompt['id'],)
209 | ).fetchall()
210 | prompt['tags'] = tags
211 |
212 | # 格式化日期时间
213 | if 'created_at' in prompt and prompt['created_at']:
214 | prompt['created_at'] = format_datetime(prompt['created_at'])
215 | if 'updated_at' in prompt and prompt['updated_at']:
216 | prompt['updated_at'] = format_datetime(prompt['updated_at'])
217 |
218 | return {
219 | 'prompts': prompts,
220 | 'current_page': page,
221 | 'total_pages': total_pages,
222 | 'total_count': total_count
223 | }
224 |
225 |
226 | def is_favorited(user_id, prompt_id):
227 | """检查用户是否已收藏提示词"""
228 | if not user_id:
229 | return False
230 |
231 | db = get_db()
232 | favorite = db.execute(
233 | 'SELECT * FROM favorites WHERE user_id = ? AND prompt_id = ?',
234 | (user_id, prompt_id)
235 | ).fetchone()
236 |
237 | return favorite is not None
238 |
239 |
240 | def toggle_favorite(user_id, prompt_id):
241 | """切换收藏状态"""
242 | db = get_db()
243 |
244 | favorite = db.execute(
245 | 'SELECT * FROM favorites WHERE user_id = ? AND prompt_id = ?',
246 | (user_id, prompt_id)
247 | ).fetchone()
248 |
249 | if favorite:
250 | # 取消收藏
251 | db.execute('DELETE FROM favorites WHERE user_id = ? AND prompt_id = ?', (user_id, prompt_id))
252 | is_favorited = False
253 | else:
254 | # 添加收藏
255 | db.execute('INSERT INTO favorites (user_id, prompt_id) VALUES (?, ?)', (user_id, prompt_id))
256 | is_favorited = True
257 |
258 | db.commit()
259 | return is_favorited
260 |
261 |
--------------------------------------------------------------------------------
/app/services/ai_service.py:
--------------------------------------------------------------------------------
1 | """
2 | AI 服务层
3 | 提供统一的 AI 元数据生成接口
4 | """
5 | import json
6 | import re
7 | from typing import Dict
8 | from flask import current_app
9 | from app.services.ai.base_client import BaseAIClient
10 |
11 |
12 | # 提示词模板(固定规则)
13 | METADATA_GENERATION_PROMPT = """你是一个专业的提示词管理助手。请根据用户提供的提示词内容,生成以下信息:
14 |
15 | 1. **标题**:一个简洁、准确的标题,能够概括提示词的核心功能或用途
16 | - 要求:不超过30个字符,使用中文
17 | - 风格:专业、简洁、易于理解
18 |
19 | 2. **描述**:一段简要的描述,说明这个提示词的用途、适用场景和特点
20 | - 要求:必须100-150个字符之间,使用中文
21 | - 内容:详细说明用途、适用场景、主要特点,确保描述充分且完整
22 | - 重要:描述字数必须严格控制在100-150字符之间,不能少于100字符,不能超过150字符
23 |
24 | 3. **标签**:生成5个相关标签,用于分类和搜索
25 | - 要求:标签使用中文或英文(技术术语)
26 | - 类型:技术标签、功能标签、场景标签
27 | - 每个标签长度2-8个字符
28 |
29 | **输出格式**:必须严格按照以下 JSON 格式返回,不要包含任何其他文字说明:
30 |
31 | ```json
32 | {{
33 | "title": "标题内容",
34 | "description": "描述内容",
35 | "tags": ["标签1", "标签2", "标签3", "标签4", "标签5"]
36 | }}
37 | ```
38 |
39 | **用户提供的提示词内容**:
40 | {prompt_content}
41 |
42 | 请开始分析并生成元数据:"""
43 |
44 |
45 | class AIService:
46 | """AI 服务类"""
47 |
48 | def __init__(self, client: BaseAIClient):
49 | """
50 | 初始化 AI 服务
51 |
52 | :param client: AI 客户端实例
53 | """
54 | self.client = client
55 |
56 | def generate_metadata(self, prompt_content: str) -> Dict:
57 | """
58 | 根据提示词内容生成元数据
59 |
60 | :param prompt_content: 提示词内容
61 | :return: 包含 title, description, tags 的字典
62 | :raises ValueError: 如果输入无效或生成失败
63 | """
64 | # 固定配置
65 | title_max_length = 30
66 | description_max_length = 150 # 最大150字符
67 | description_min_length = 100 # 最小100字符
68 | tag_count = 5
69 | tag_two_char_count = 0
70 | tag_four_char_count = 0
71 | # 验证输入
72 | if not prompt_content or len(prompt_content.strip()) == 0:
73 | raise ValueError("提示词内容不能为空")
74 |
75 | if len(prompt_content) > 10000:
76 | raise ValueError("提示词内容过长(最大10000字符)")
77 |
78 | # 构建提示词(使用固定规则)
79 | full_prompt = self._build_prompt(prompt_content=prompt_content)
80 |
81 | # 计算合适的 max_tokens
82 | # 标题大约需要 50 tokens,描述需要更多(150字符约300 tokens),标签每个约 10 tokens
83 | # 加上 JSON 格式的开销,总需求约为:50 + 300 + tag_count * 10 + 100
84 | estimated_tokens = 50 + 300 + (tag_count * 10) + 100
85 | # 确保至少 500,最多不超过 2000
86 | max_tokens = max(500, min(estimated_tokens, 2000))
87 |
88 | # 调用 AI API
89 | messages = [
90 | {"role": "system", "content": "你是一个专业的提示词管理助手,擅长分析提示词内容并生成准确的元数据。"},
91 | {"role": "user", "content": full_prompt}
92 | ]
93 |
94 | try:
95 | response = self.client.chat_completion(messages, max_tokens=max_tokens)
96 | content = self.client.extract_text_from_response(response)
97 |
98 | # 解析 JSON 响应
99 | metadata = self._parse_response(content)
100 |
101 | # 验证和清理数据(使用固定参数)
102 | metadata = self._validate_and_clean_metadata(
103 | metadata,
104 | title_max_length=title_max_length,
105 | description_min_length=description_min_length,
106 | description_max_length=description_max_length,
107 | tag_count=tag_count,
108 | tag_two_char_count=tag_two_char_count,
109 | tag_four_char_count=tag_four_char_count
110 | )
111 |
112 | return metadata
113 |
114 | except json.JSONDecodeError as e:
115 | raise ValueError(f"AI 返回的 JSON 格式错误: {str(e)}")
116 | except Exception as e:
117 | raise Exception(f"生成元数据失败: {str(e)}")
118 |
119 | def _build_prompt(self, prompt_content: str) -> str:
120 | """
121 | 构建提示词(使用固定规则)
122 |
123 | :param prompt_content: 提示词内容
124 | :return: 完整的提示词
125 | """
126 | return METADATA_GENERATION_PROMPT.format(
127 | prompt_content=prompt_content
128 | )
129 |
130 | def _parse_response(self, content: str) -> Dict:
131 | """
132 | 解析 AI 返回的内容
133 |
134 | :param content: AI 返回的文本内容
135 | :return: 解析后的字典
136 | :raises ValueError: 如果无法解析 JSON
137 | """
138 | # 尝试直接解析 JSON
139 | try:
140 | # 移除可能的代码块标记
141 | content = re.sub(r'```json\s*', '', content)
142 | content = re.sub(r'```\s*', '', content)
143 | content = content.strip()
144 |
145 | return json.loads(content)
146 | except json.JSONDecodeError:
147 | # 如果直接解析失败,尝试提取 JSON 部分
148 | json_match = re.search(r'\{[^{}]*"title"[^{}]*\}', content, re.DOTALL)
149 | if json_match:
150 | return json.loads(json_match.group())
151 | raise ValueError("无法从响应中提取有效的 JSON 数据")
152 |
153 | def _validate_and_clean_metadata(self, metadata: Dict,
154 | title_max_length: int = 30,
155 | description_min_length: int = 100,
156 | description_max_length: int = 150,
157 | tag_count: int = 5,
158 | tag_two_char_count: int = 0,
159 | tag_four_char_count: int = 0) -> Dict:
160 | """
161 | 验证和清理元数据
162 |
163 | :param metadata: AI 返回的元数据
164 | :param title_max_length: 标题最大字数
165 | :param description_max_length: 描述最大字数
166 | :param tag_count: 标签总数
167 | :param tag_two_char_count: 两字标签个数
168 | :param tag_four_char_count: 四字标签个数
169 | :return: 清理后的元数据
170 | :raises ValueError: 如果数据无效
171 | """
172 | # 验证必需字段
173 | if 'title' not in metadata or 'description' not in metadata or 'tags' not in metadata:
174 | raise ValueError("AI 返回的数据缺少必需字段")
175 |
176 | # 清理标题(使用配置的长度限制)
177 | title = str(metadata['title']).strip()
178 | if len(title) > title_max_length:
179 | title = title[:title_max_length]
180 | if not title:
181 | raise ValueError("标题不能为空")
182 |
183 | # 清理描述(使用固定的长度限制:100-150字符)
184 | description = str(metadata['description']).strip()
185 | if len(description) > description_max_length:
186 | description = description[:description_max_length]
187 | elif len(description) < description_min_length:
188 | # 如果描述太短,提示但不强制(因为可能是 AI 生成的问题)
189 | current_app.logger.warning(f"描述字数不足:{len(description)} < {description_min_length}")
190 | if not description:
191 | raise ValueError("描述不能为空")
192 |
193 | # 清理标签(根据配置规则)
194 | tags = metadata.get('tags', [])
195 | if not isinstance(tags, list):
196 | tags = [tags] if tags else []
197 |
198 | # 过滤和清理标签
199 | cleaned_tags = []
200 | two_char_tags = []
201 | four_char_tags = []
202 | other_tags = []
203 |
204 | for tag in tags:
205 | tag = str(tag).strip()
206 | if not tag or len(tag) < 2 or len(tag) > 20:
207 | continue
208 |
209 | if len(tag) == 2:
210 | two_char_tags.append(tag)
211 | elif len(tag) == 4:
212 | four_char_tags.append(tag)
213 | else:
214 | other_tags.append(tag)
215 |
216 | # 按照配置规则选择标签
217 | if tag_two_char_count > 0:
218 | cleaned_tags.extend(two_char_tags[:tag_two_char_count])
219 | if tag_four_char_count > 0:
220 | cleaned_tags.extend(four_char_tags[:tag_four_char_count])
221 |
222 | # 填充剩余标签
223 | remaining = tag_count - len(cleaned_tags)
224 | if remaining > 0:
225 | all_remaining = two_char_tags[tag_two_char_count:] + \
226 | four_char_tags[tag_four_char_count:] + \
227 | other_tags
228 | cleaned_tags.extend(all_remaining[:remaining])
229 |
230 | # 确保标签数量在合理范围内
231 | if len(cleaned_tags) < 1:
232 | raise ValueError("至少需要一个标签")
233 | if len(cleaned_tags) > tag_count:
234 | cleaned_tags = cleaned_tags[:tag_count]
235 |
236 | return {
237 | 'title': title,
238 | 'description': description,
239 | 'tags': cleaned_tags
240 | }
241 |
242 | def test_connection(self) -> bool:
243 | """
244 | 测试 API 连接
245 |
246 | :return: True 如果连接成功,False 否则
247 | """
248 | return self.client.test_connection()
249 |
250 |
--------------------------------------------------------------------------------
/app/templates/search.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %} {% block title %}搜索结果 - 提示词管理平台{% endblock
2 | %} {% block styles %}
3 |
228 | {% endblock %} {% block content %}
229 |
230 |
231 |
232 |
233 |
234 | 搜索结果 {% if query or selected_tag %}
235 |
236 | {% if query %}关键词: "{{ query }}"{% endif %} {% if query and
237 | selected_tag %} 和 {% endif %} {% if selected_tag %}标签: "{{
238 | selected_tag }}"{% endif %}
239 |
240 | {% endif %}
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 | {% if prompts %} {% for prompt in prompts %}
251 |
255 |
263 |
264 |
{{ prompt.description or '无描述' }}
265 | {% if prompt.tags %}
266 |
267 | {% for tag in prompt.tags %}
268 |
{{ tag.name }}
274 | {% endfor %}
275 |
276 | {% endif %}
277 |
278 |
297 |
298 | {% endfor %} {% else %}
299 |
300 |
301 |
302 |
303 |
未找到匹配的提示词
304 |
尝试更改搜索条件或浏览其他提示词
305 |
306 | 返回首页
307 |
308 |
309 | {% endif %}
310 |
311 |
312 |
313 |
314 | {% endblock %} {% block scripts %}
315 |
343 | {% endblock %}
344 |
--------------------------------------------------------------------------------
/app/templates/admin/invite_codes.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %} {% block title %}邀请码管理 - 提示词管理平台{%
2 | endblock %} {% block styles %}
3 |
124 | {% endblock %} {% block content %}
125 |
126 |
127 |
128 |
129 |
邀请码管理
130 |
131 |
132 |
133 |
134 |
135 |
169 |
170 |
258 |
259 | {% endblock %} {% block scripts %}
260 |
325 | {% endblock %}
326 |
--------------------------------------------------------------------------------
/app/templates/user/profile.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %} {% block title %}{{ profile_user.username }}
2 | 的个人资料 - 提示词管理平台{% endblock %} {% block styles %}
3 |
263 | {% endblock %} {% block content %}
264 |
265 |
266 |
318 |
319 |
320 |
323 |
324 |
325 | {% if prompts %}
326 |
327 | {% for prompt in prompts %}
328 |
329 |
344 |
345 |
{{ prompt.description }}
346 |
347 | {% if prompt.tags %} {% for tag in prompt.tags %}
348 | {{ tag }}
349 | {% endfor %} {% endif %}
350 |
351 |
352 |
369 |
370 | {% endfor %}
371 |
372 | {% else %}
373 |
374 |
375 |
暂无收藏
376 |
您还没有收藏任何提示词
377 |
378 | {% endif %}
379 |
380 | {% endblock %}
381 |
--------------------------------------------------------------------------------
/app/templates/prompts/my_prompts.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %} {% block title %}我的提示词 - 提示词管理平台{%
2 | endblock %} {% block styles %}
3 |
277 | {% endblock %} {% block content %}
278 |
279 |
280 |
281 |
282 |
283 | {% for prompt in prompts %}
284 |
288 |
296 |
297 |
{{ prompt.description or '无描述' }}
298 | {% if prompt.tags %}
299 |
300 | {% for tag in prompt.tags %}
301 |
{{ tag.name }}
307 | {% endfor %}
308 |
309 | {% endif %}
310 |
311 |
334 |
335 | {% endfor %}
336 |
337 |
338 | {% if prompts %}
339 |
381 |
382 | 显示第 {{ (current_page - 1) * 9 + 1 }} 到 {% if current_page * 9 >
383 | total_count %} {{ total_count }} {% else %} {{ current_page * 9 }} {%
384 | endif %} 条,共 {{ total_count }} 条
385 |
386 | {% else %}
387 |
388 |
392 |
您还没有创建任何提示词
393 |
开始创建你的第一个提示词吧!
394 |
395 | {% endif %}
396 |
397 |
398 |
399 | {% endblock %} {% block scripts %}
400 |
418 | {% endblock %}
419 |
--------------------------------------------------------------------------------
/app/routes/user.py:
--------------------------------------------------------------------------------
1 | """
2 | 用户相关路由
3 | """
4 | from flask import Blueprint, render_template, redirect, url_for, flash, request, session, jsonify
5 | from werkzeug.security import generate_password_hash, check_password_hash
6 | from app.database import get_db
7 | from app.utils.decorators import login_required
8 | from app.utils.helpers import format_datetime
9 | from app.utils.file_upload import save_avatar
10 | from app.utils.encryption import encrypt_string, decrypt_string
11 | from app.services.ai.factory import AIClientFactory
12 | from flask import current_app
13 | import datetime
14 |
15 | bp = Blueprint('user', __name__)
16 |
17 |
18 | @bp.route('/profile/')
19 | @login_required
20 | def profile(user_id):
21 | """用户个人资料"""
22 | db = get_db()
23 |
24 | try:
25 | # 获取用户信息
26 | user_row = db.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
27 | if not user_row:
28 | flash('用户不存在', 'danger')
29 | return redirect(url_for('main.index'))
30 |
31 | profile_user = dict(user_row)
32 |
33 | # 只获取用户收藏的提示词
34 | prompt_rows = db.execute('''
35 | SELECT p.*, u.username, f.created_at as favorited_at
36 | FROM favorites f
37 | JOIN prompts p ON f.prompt_id = p.id
38 | JOIN users u ON p.user_id = u.id
39 | WHERE f.user_id = ?
40 | ORDER BY f.created_at DESC
41 | ''', (user_id,)).fetchall()
42 |
43 | prompts = [dict(row) for row in prompt_rows] if prompt_rows else []
44 |
45 | # 获取用户提示词浏览量总数
46 | views_count_result = db.execute('''
47 | SELECT COALESCE(SUM(view_count), 0) as total_views
48 | FROM prompts
49 | WHERE user_id = ?
50 | ''', (user_id,)).fetchone()
51 | views_count = views_count_result['total_views'] if views_count_result else 0
52 |
53 | # 获取用户收藏的提示词数量
54 | likes_count_result = db.execute('''
55 | SELECT COUNT(*) as count FROM favorites WHERE user_id = ?
56 | ''', (user_id,)).fetchone()
57 | likes_count = likes_count_result['count'] if likes_count_result else 0
58 |
59 | # 获取用户创建的提示词数量
60 | prompts_count_result = db.execute('''
61 | SELECT COUNT(*) as count FROM prompts WHERE user_id = ?
62 | ''', (user_id,)).fetchone()
63 | prompts_count = prompts_count_result['count'] if prompts_count_result else 0
64 |
65 | # 为每个提示词加载标签
66 | for prompt in prompts:
67 | try:
68 | tags = db.execute('''
69 | SELECT t.name
70 | FROM tags t
71 | JOIN tags_prompts tp ON t.id = tp.tag_id
72 | WHERE tp.prompt_id = ?
73 | ''', (prompt['id'],)).fetchall()
74 | prompt['tags'] = [tag['name'] for tag in tags] if tags else []
75 |
76 | # 格式化日期时间
77 | if 'created_at' in prompt and prompt['created_at']:
78 | prompt['created_at'] = format_datetime(prompt['created_at'])
79 | if 'updated_at' in prompt and prompt['updated_at']:
80 | prompt['updated_at'] = format_datetime(prompt['updated_at'])
81 | if 'favorited_at' in prompt and prompt['favorited_at']:
82 | prompt['favorited_at'] = format_datetime(prompt['favorited_at'])
83 |
84 | if 'view_count' not in prompt or prompt['view_count'] is None:
85 | prompt['view_count'] = 0
86 | except Exception as e:
87 | current_app.logger.error(f"处理提示词数据出错: {str(e)}, 提示词ID: {prompt.get('id', 'unknown')}")
88 | continue
89 |
90 | # 处理profile_user的created_at
91 | if 'created_at' in profile_user and profile_user['created_at']:
92 | profile_user['created_at'] = format_datetime(profile_user['created_at'])
93 |
94 | return render_template('user/profile.html',
95 | profile_user=profile_user,
96 | prompts=prompts,
97 | views_count=views_count,
98 | likes_count=likes_count,
99 | prompts_count=prompts_count,
100 | now=datetime.datetime.now())
101 |
102 | except Exception as e:
103 | current_app.logger.error(f"个人资料页面错误: {str(e)}")
104 | flash('加载个人资料时出错', 'danger')
105 | return redirect(url_for('main.index'))
106 |
107 |
108 | @bp.route('/profile/edit', methods=['GET', 'POST'])
109 | @login_required
110 | def edit_profile():
111 | """编辑个人资料"""
112 | user_id = session['user_id']
113 | db = get_db()
114 |
115 | user_row = db.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
116 | if not user_row:
117 | flash('用户不存在', 'danger')
118 | return redirect(url_for('main.index'))
119 |
120 | user = dict(user_row)
121 |
122 | # 获取当前tab(用于显示哪个tab)
123 | current_tab = request.args.get('tab', 'basic')
124 |
125 | # 处理AI设置表单提交(通过检查表单字段判断)
126 | if request.method == 'POST' and request.form.get('provider'):
127 | provider = request.form.get('provider', 'openai')
128 | api_key = request.form.get('api_key', '').strip()
129 | base_url = request.form.get('base_url', '').strip() or None
130 | model = request.form.get('model', 'gpt-3.5-turbo')
131 | temperature = float(request.form.get('temperature', 0.7))
132 | max_tokens = int(request.form.get('max_tokens', 500))
133 | enabled = 'enabled' in request.form
134 |
135 | # 获取现有配置
136 | config = db.execute(
137 | 'SELECT * FROM ai_configs WHERE user_id = ?',
138 | (user_id,)
139 | ).fetchone()
140 |
141 | error = None
142 |
143 | # 处理 API Key
144 | if config:
145 | if not api_key or api_key.strip() == '':
146 | encrypted_api_key = config['api_key']
147 | else:
148 | encrypted_api_key = encrypt_string(api_key)
149 | else:
150 | if not api_key or api_key.strip() == '':
151 | error = 'API Key 不能为空'
152 | else:
153 | encrypted_api_key = encrypt_string(api_key)
154 |
155 | # 验证其他字段
156 | if not error and provider == 'custom' and not base_url:
157 | error = '自定义 API 必须提供基础 URL'
158 |
159 | if error:
160 | flash(error, 'danger')
161 | else:
162 | try:
163 | if config:
164 | db.execute('''
165 | UPDATE ai_configs SET
166 | provider = ?, api_key = ?, base_url = ?, model = ?,
167 | temperature = ?, max_tokens = ?, enabled = ?,
168 | updated_at = CURRENT_TIMESTAMP
169 | WHERE user_id = ?
170 | ''', (
171 | provider, encrypted_api_key, base_url, model,
172 | temperature, max_tokens, enabled,
173 | user_id
174 | ))
175 | else:
176 | db.execute('''
177 | INSERT INTO ai_configs (
178 | user_id, provider, api_key, base_url, model,
179 | temperature, max_tokens, enabled
180 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
181 | ''', (
182 | user_id, provider, encrypted_api_key, base_url, model,
183 | temperature, max_tokens, enabled
184 | ))
185 |
186 | db.commit()
187 | flash('AI 设置已保存', 'success')
188 | return redirect(url_for('user.edit_profile', tab='ai'))
189 |
190 | except Exception as e:
191 | db.rollback()
192 | current_app.logger.error(f'保存 AI 设置失败: {str(e)}')
193 | flash(f'保存失败: {str(e)}', 'danger')
194 |
195 | # 处理基本资料和安全设置表单提交
196 | elif request.method == 'POST':
197 | username = request.form['username']
198 | email = request.form['email']
199 | current_password = request.form.get('current_password', '')
200 | new_password = request.form.get('new_password', '')
201 | confirm_password = request.form.get('confirm_password', '')
202 |
203 | avatar_file = request.files.get('avatar')
204 |
205 | error = None
206 | if not username:
207 | error = '用户名不能为空'
208 | elif not email or '@' not in email:
209 | error = '请输入有效的邮箱地址'
210 |
211 | if not error:
212 | existing_user = db.execute(
213 | 'SELECT * FROM users WHERE (username = ? OR email = ?) AND id != ?',
214 | (username, email, user_id)
215 | ).fetchone()
216 |
217 | if existing_user:
218 | if existing_user['username'] == username:
219 | error = f'用户名 {username} 已被使用'
220 | else:
221 | error = f'邮箱 {email} 已被使用'
222 |
223 | if not error and new_password:
224 | if not check_password_hash(user['password_hash'], current_password):
225 | error = '当前密码不正确'
226 | elif len(new_password) < 8:
227 | error = '新密码长度至少为8个字符'
228 | elif new_password != confirm_password:
229 | error = '新密码和确认密码不匹配'
230 |
231 | if error:
232 | flash(error, 'danger')
233 | else:
234 | try:
235 | avatar_url = None
236 | if avatar_file and avatar_file.filename:
237 | avatar_url = save_avatar(avatar_file)
238 |
239 | if avatar_url:
240 | db.execute(
241 | 'UPDATE users SET username = ?, email = ?, avatar_url = ? WHERE id = ?',
242 | (username, email, avatar_url, user_id)
243 | )
244 | session['avatar_url'] = avatar_url
245 | else:
246 | db.execute(
247 | 'UPDATE users SET username = ?, email = ? WHERE id = ?',
248 | (username, email, user_id)
249 | )
250 |
251 | if new_password:
252 | hashed_password = generate_password_hash(new_password, method='pbkdf2:sha256')
253 | db.execute(
254 | 'UPDATE users SET password_hash = ? WHERE id = ?',
255 | (hashed_password, user_id)
256 | )
257 |
258 | db.commit()
259 | flash('个人资料已更新', 'success')
260 | session['username'] = username
261 |
262 | return redirect(url_for('user.profile', user_id=user_id))
263 | except Exception as e:
264 | db.rollback()
265 | current_app.logger.error(f'更新个人资料失败: {str(e)}')
266 | flash(f'更新失败: {str(e)}', 'danger')
267 |
268 | # 获取AI配置用于显示
269 | config = db.execute(
270 | 'SELECT * FROM ai_configs WHERE user_id = ?',
271 | (user_id,)
272 | ).fetchone()
273 |
274 | config_dict = dict(config) if config else None
275 | if config_dict and config_dict.get('api_key'):
276 | try:
277 | decrypted_key = decrypt_string(config_dict['api_key'])
278 | if len(decrypted_key) > 8:
279 | config_dict['api_key_display'] = decrypted_key[:4] + '*' * (len(decrypted_key) - 8) + decrypted_key[-4:]
280 | else:
281 | config_dict['api_key_display'] = '*' * len(decrypted_key)
282 | except Exception:
283 | config_dict['api_key_display'] = '已配置(无法显示)'
284 |
285 | now = datetime.datetime.now()
286 | return render_template('user/edit_profile.html', user=user, now=now, config=config_dict, current_tab=current_tab)
287 |
288 |
289 | @bp.route('/ai-settings', methods=['GET', 'POST'])
290 | @login_required
291 | def ai_settings():
292 | """AI 设置页面(重定向到profile/edit?tab=ai)"""
293 | return redirect(url_for('user.edit_profile', tab='ai'))
294 |
295 |
--------------------------------------------------------------------------------
/app/templates/user/ai_settings.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %} {% block title %}AI 设置 - 提示词管理平台{% endblock %} {% block styles %}
2 |
98 | {% endblock %} {% block content %}
99 |
272 | {% endblock %} {% block scripts %}
273 |
367 | {% endblock %}
368 |
369 |
--------------------------------------------------------------------------------
/app/static/js/main.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 提示词管理平台 - 主JavaScript文件
3 | */
4 |
5 | // 等待文档加载完成
6 | document.addEventListener("DOMContentLoaded", function () {
7 | // 初始化所有功能
8 | initTooltips();
9 | initAnimations();
10 | initCopyButtons();
11 | initTagEffects();
12 | initFlashMessages();
13 | initFormValidation();
14 | initImagePreview();
15 | initScrollNavbar();
16 | initCardAnimations();
17 | initPageTransitions();
18 | initStaggeredItems();
19 |
20 | // 删除此处的回到顶部按钮代码,避免冲突
21 | console.log("main.js初始化完成,回到顶部按钮已由HTML内联脚本处理");
22 | });
23 |
24 | /**
25 | * 初始化 Bootstrap 工具提示
26 | */
27 | function initTooltips() {
28 | const tooltipTriggerList = document.querySelectorAll(
29 | '[data-bs-toggle="tooltip"]'
30 | );
31 | if (tooltipTriggerList.length > 0 && typeof bootstrap !== "undefined") {
32 | tooltipTriggerList.forEach(function (tooltipTriggerEl) {
33 | new bootstrap.Tooltip(tooltipTriggerEl);
34 | });
35 | console.log("工具提示已初始化");
36 | }
37 | }
38 |
39 | /**
40 | * 初始化动画效果
41 | */
42 | function initAnimations() {
43 | const mainContent = document.querySelector("main");
44 | if (mainContent) {
45 | mainContent.classList.add("fade-in");
46 | }
47 |
48 | const headings = document.querySelectorAll("h1, h2, h3");
49 | headings.forEach(function (heading) {
50 | heading.classList.add("fadeIn");
51 | });
52 | }
53 |
54 | /**
55 | * 初始化复制按钮功能
56 | */
57 | function initCopyButtons() {
58 | const copyButtons = document.querySelectorAll("[data-copy-target]");
59 |
60 | copyButtons.forEach(function (button) {
61 | button.addEventListener("click", function () {
62 | const targetId = button.getAttribute("data-copy-target");
63 | const targetElement = document.getElementById(targetId);
64 |
65 | if (targetElement) {
66 | if (
67 | targetElement.tagName === "INPUT" ||
68 | targetElement.tagName === "TEXTAREA"
69 | ) {
70 | copyToClipboard(targetElement.value, button);
71 | } else {
72 | copyToClipboard(targetElement.textContent, button);
73 | }
74 | }
75 | });
76 | });
77 |
78 | const copyTextButtons = document.querySelectorAll("[data-copy-text]");
79 |
80 | copyTextButtons.forEach(function (button) {
81 | button.addEventListener("click", function () {
82 | const text = button.getAttribute("data-copy-text");
83 | copyToClipboard(text, button);
84 | });
85 | });
86 | }
87 |
88 | /**
89 | * 复制内容到剪贴板
90 | */
91 | function copyToClipboard(text, button) {
92 | // 保存原始按钮文本,用于恢复
93 | const originalText = button.innerHTML;
94 |
95 | // 检查是否支持现代Clipboard API
96 | if (navigator.clipboard && window.isSecureContext) {
97 | // 使用现代Clipboard API
98 | navigator.clipboard
99 | .writeText(text)
100 | .then(() => {
101 | // 复制成功
102 | button.innerHTML = ' 已复制';
103 |
104 | // 显示成功消息(可选)
105 | if (window.innerWidth < 768) {
106 | // 移动端显示toast提示
107 | showToast("复制成功", "success");
108 | }
109 |
110 | // 2秒后恢复按钮原样
111 | setTimeout(function () {
112 | button.innerHTML = originalText;
113 | }, 2000);
114 | })
115 | .catch((err) => {
116 | console.error("复制失败:", err);
117 | button.innerHTML =
118 | ' 复制失败';
119 |
120 | // 添加备用复制方法
121 | fallbackCopyToClipboard(text, button, originalText);
122 |
123 | setTimeout(function () {
124 | button.innerHTML = originalText;
125 | }, 2000);
126 | });
127 | } else {
128 | // 对于不支持Clipboard API的浏览器,使用备用方法
129 | fallbackCopyToClipboard(text, button, originalText);
130 | }
131 | }
132 |
133 | /**
134 | * 备用的复制方法,使用传统技术
135 | */
136 | function fallbackCopyToClipboard(text, button, originalText) {
137 | try {
138 | // 创建临时textarea
139 | const textarea = document.createElement("textarea");
140 | textarea.value = text;
141 |
142 | // 确保textarea在移动设备上可见(在视口内但不可见)
143 | textarea.style.position = "fixed";
144 | textarea.style.left = "0";
145 | textarea.style.top = "0";
146 | textarea.style.opacity = "0";
147 | textarea.style.width = "100%";
148 | textarea.style.height = "100%";
149 |
150 | document.body.appendChild(textarea);
151 |
152 | // 特殊处理iOS设备
153 | if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
154 | const range = document.createRange();
155 | range.selectNodeContents(textarea);
156 | const selection = window.getSelection();
157 | selection.removeAllRanges();
158 | selection.addRange(range);
159 | textarea.setSelectionRange(0, 999999);
160 | } else {
161 | textarea.select();
162 | }
163 |
164 | const successful = document.execCommand("copy");
165 | document.body.removeChild(textarea);
166 |
167 | if (successful) {
168 | button.innerHTML = ' 已复制';
169 |
170 | // 移动端显示额外提示
171 | if (window.innerWidth < 768) {
172 | showToast("复制成功", "success");
173 | }
174 | } else {
175 | console.error("复制失败");
176 | button.innerHTML =
177 | ' 失败,请手动复制';
178 |
179 | // 移动端显示提示
180 | if (window.innerWidth < 768) {
181 | showToast("请手动长按并复制内容", "warning");
182 | }
183 | }
184 | } catch (err) {
185 | console.error("复制过程中出错:", err);
186 | button.innerHTML =
187 | ' 失败,请手动复制';
188 | }
189 |
190 | // 2秒后恢复按钮原样
191 | setTimeout(function () {
192 | button.innerHTML = originalText;
193 | }, 2000);
194 | }
195 |
196 | /**
197 | * 显示Toast提示消息(不占据页面布局)
198 | * @param {string} message - 消息内容
199 | * @param {string} type - 消息类型:success, danger, warning, info
200 | * @param {number} duration - 显示时长(毫秒),默认3000,0表示不自动关闭
201 | */
202 | function showToastNotification(message, type = "success", duration = 3000) {
203 | const container = document.getElementById("toast-container");
204 | if (!container) {
205 | console.error("Toast container not found");
206 | return;
207 | }
208 |
209 | // 创建 Toast 元素
210 | const toast = document.createElement("div");
211 | toast.className = `toast-notification toast-${type}`;
212 | toast.setAttribute("role", "alert");
213 | toast.setAttribute("aria-live", "assertive");
214 |
215 | // 根据类型设置图标
216 | let icon = "bi-info-circle";
217 | if (type === "success") {
218 | icon = "bi-check-circle";
219 | } else if (type === "danger") {
220 | icon = "bi-exclamation-circle";
221 | } else if (type === "warning") {
222 | icon = "bi-exclamation-triangle";
223 | }
224 |
225 | // 设置内容
226 | toast.innerHTML = `
227 |
228 | ${escapeHtml(message)}
229 |
232 | `;
233 |
234 | // 添加到容器
235 | container.appendChild(toast);
236 |
237 | // 关闭按钮事件
238 | const closeBtn = toast.querySelector(".toast-close");
239 | closeBtn.addEventListener("click", () => {
240 | removeToast(toast);
241 | });
242 |
243 | // 自动关闭
244 | if (duration > 0) {
245 | setTimeout(() => {
246 | removeToast(toast);
247 | }, duration);
248 | }
249 |
250 | return toast;
251 | }
252 |
253 | /**
254 | * 移除 Toast 通知
255 | */
256 | function removeToast(toast) {
257 | if (!toast || !toast.parentNode) return;
258 |
259 | toast.classList.add("toast-fade-out");
260 | setTimeout(() => {
261 | if (toast.parentNode) {
262 | toast.remove();
263 | }
264 | }, 300);
265 | }
266 |
267 | /**
268 | * HTML 转义函数
269 | */
270 | function escapeHtml(text) {
271 | const div = document.createElement("div");
272 | div.textContent = text;
273 | return div.innerHTML;
274 | }
275 |
276 | /**
277 | * 显示Toast提示消息(兼容旧版本)
278 | * @deprecated 使用 showToastNotification 代替
279 | */
280 | function showToast(message, type = "success") {
281 | showToastNotification(message, type, 2000);
282 | }
283 |
284 | /**
285 | * 初始化标签点击动画
286 | */
287 | function initTagEffects() {
288 | const tags = document.querySelectorAll(".tag, .tag-pill");
289 |
290 | tags.forEach(function (tag) {
291 | tag.addEventListener("mouseenter", function () {
292 | this.style.transform = "translateY(-5px) scale(1.05)";
293 | });
294 |
295 | tag.addEventListener("mouseleave", function () {
296 | this.style.transform = "";
297 | });
298 | });
299 | }
300 |
301 | /**
302 | * 初始化闪烁消息自动消失
303 | */
304 | function initFlashMessages() {
305 | const flashMessages = document.querySelectorAll(".alert");
306 |
307 | flashMessages.forEach(function (message) {
308 | message.classList.add("fade-in");
309 |
310 | const closeButton = message.querySelector(".btn-close");
311 | if (closeButton) {
312 | closeButton.addEventListener("click", function () {
313 | message.classList.add("fade-out");
314 |
315 | setTimeout(function () {
316 | message.remove();
317 | }, 500);
318 | });
319 | }
320 |
321 | if (!message.classList.contains("alert-danger")) {
322 | setTimeout(function () {
323 | message.classList.add("fade-out");
324 |
325 | setTimeout(function () {
326 | if (message.parentNode) {
327 | message.remove();
328 | }
329 | }, 500);
330 | }, 5000);
331 | }
332 | });
333 | }
334 |
335 | /**
336 | * 表单验证
337 | */
338 | function initFormValidation() {
339 | const forms = document.querySelectorAll(".needs-validation");
340 |
341 | forms.forEach(function (form) {
342 | form.addEventListener(
343 | "submit",
344 | function (event) {
345 | if (!form.checkValidity()) {
346 | event.preventDefault();
347 | event.stopPropagation();
348 | }
349 |
350 | form.classList.add("was-validated");
351 | },
352 | false
353 | );
354 | });
355 | }
356 |
357 | /**
358 | * 图片预览
359 | */
360 | function initImagePreview() {
361 | const fileInputs = document.querySelectorAll(
362 | 'input[type="file"][data-preview-target]'
363 | );
364 |
365 | fileInputs.forEach(function (input) {
366 | input.addEventListener("change", function () {
367 | const targetId = input.getAttribute("data-preview-target");
368 | const previewElement = document.getElementById(targetId);
369 |
370 | if (previewElement && input.files && input.files[0]) {
371 | const reader = new FileReader();
372 |
373 | reader.onload = function (e) {
374 | if (previewElement.tagName === "IMG") {
375 | previewElement.src = e.target.result;
376 | previewElement.style.display = "block";
377 | } else {
378 | previewElement.style.backgroundImage = `url(${e.target.result})`;
379 | }
380 |
381 | previewElement.classList.add("preview-loaded");
382 | };
383 |
384 | reader.readAsDataURL(input.files[0]);
385 | }
386 | });
387 | });
388 | }
389 |
390 | /**
391 | * 初始化滚动导航栏
392 | */
393 | function initScrollNavbar() {
394 | let lastScrollTop = 0;
395 | const navbar = document.querySelector(".navbar");
396 | const scrollThreshold = 100;
397 |
398 | if (navbar) {
399 | navbar.classList.add("navbar-visible");
400 |
401 | window.addEventListener(
402 | "scroll",
403 | function () {
404 | const currentScrollTop =
405 | window.pageYOffset || document.documentElement.scrollTop;
406 |
407 | if (currentScrollTop > scrollThreshold) {
408 | if (currentScrollTop > lastScrollTop) {
409 | navbar.classList.remove("navbar-visible");
410 | navbar.classList.add("navbar-hidden");
411 | } else {
412 | navbar.classList.remove("navbar-hidden");
413 | navbar.classList.add("navbar-visible");
414 | }
415 | } else {
416 | navbar.classList.remove("navbar-hidden");
417 | navbar.classList.add("navbar-visible");
418 | }
419 |
420 | setTimeout(function () {
421 | lastScrollTop = currentScrollTop;
422 | }, 100);
423 | },
424 | { passive: true }
425 | );
426 | }
427 | }
428 |
429 | /**
430 | * 页面过渡动画
431 | */
432 | function initPageTransitions() {
433 | document.querySelectorAll("a").forEach((link) => {
434 | if (
435 | link.hostname === window.location.hostname &&
436 | !link.hasAttribute("data-no-transition") &&
437 | !link.getAttribute("href").startsWith("#") &&
438 | !link.getAttribute("href").includes("javascript:")
439 | ) {
440 | link.addEventListener("click", function (e) {
441 | e.preventDefault();
442 |
443 | const targetUrl = this.getAttribute("href");
444 |
445 | document.body.classList.add("page-exit");
446 |
447 | setTimeout(() => {
448 | window.location.href = targetUrl;
449 | }, 300);
450 | });
451 | }
452 | });
453 |
454 | window.addEventListener("pageshow", function () {
455 | document.body.classList.add("page-enter");
456 | document.body.classList.remove("page-exit");
457 | });
458 | }
459 |
460 | /**
461 | * 为卡片添加进场动画
462 | */
463 | function initCardAnimations() {
464 | const cards = document.querySelectorAll(".card");
465 |
466 | if (cards.length > 0) {
467 | const observer = new IntersectionObserver(
468 | (entries) => {
469 | entries.forEach((entry) => {
470 | if (entry.isIntersecting) {
471 | entry.target.classList.add("card-visible");
472 | observer.unobserve(entry.target);
473 | }
474 | });
475 | },
476 | {
477 | threshold: 0.1,
478 | }
479 | );
480 |
481 | cards.forEach((card, index) => {
482 | card.classList.add("card-animate");
483 | card.style.animationDelay = `${index * 0.05}s`;
484 | observer.observe(card);
485 | });
486 | }
487 | }
488 |
489 | /**
490 | * 执行错开动画的元素
491 | */
492 | function initStaggeredItems() {
493 | const staggerContainers = document.querySelectorAll(".stagger-container");
494 |
495 | staggerContainers.forEach((container) => {
496 | const items = container.querySelectorAll(".stagger-item");
497 |
498 | const observer = new IntersectionObserver(
499 | (entries) => {
500 | if (entries[0].isIntersecting) {
501 | items.forEach((item, index) => {
502 | setTimeout(() => {
503 | item.classList.add("stagger-visible");
504 | }, index * 100);
505 | });
506 | observer.unobserve(container);
507 | }
508 | },
509 | {
510 | threshold: 0.1,
511 | }
512 | );
513 |
514 | observer.observe(container);
515 | });
516 | }
517 |
--------------------------------------------------------------------------------
/app/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
14 |
15 |
16 | {% block title %}提示词管理平台{% endblock %}
17 |
18 |
19 |
24 |
30 |
36 |
40 |
45 |
49 |
50 |
51 |
52 |
53 |
54 |
58 |
59 |
63 |
64 |
68 |
69 |
73 |
238 | {% block styles %}{% endblock %}
239 |
240 |
241 |
242 |
395 |
396 |
397 |
398 |
399 |
400 | {% block content %}{% endblock %}
401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
420 |
421 |
422 |
458 |
459 | {% block scripts %}{% endblock %}
460 |
461 |
462 |
--------------------------------------------------------------------------------