├── 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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /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 |
52 |
53 | 60 | 61 | 119 |
120 |
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 |
59 |
60 |
61 | 64 |

创建新账号

65 |

加入提示词管理平台

66 |
67 | 68 |
69 |
70 |
71 | 79 | 80 |
81 | 82 |
83 | 91 | 92 |
93 | 94 |
95 | 104 | 105 | 109 | 110 | 111 |
112 | 113 |
114 | 122 | 125 | 129 | 130 | 131 |
132 | 133 |
134 | 142 | 145 |
146 | 147 |
148 | 151 |
152 | 153 |
154 |

155 | 已有账号? 156 | 立即登录 157 |

158 |
159 |
160 |
161 |
162 |
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 |
191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | {% for user in users %} 205 | 206 | 207 | 208 | 209 | 210 | 217 | 224 | 268 | 269 | {% else %} 270 | 271 | 272 | 273 | {% endfor %} 274 | 275 |
ID用户名邮箱注册时间身份状态操作
{{ user.id }}{{ user.username }}{{ user.email }}{{ user.created_at }} 211 | {% if user.is_admin %} 212 | 管理员 213 | {% else %} 214 | 普通用户 215 | {% endif %} 216 | 218 | {% if user.is_banned %} 219 | 已封禁 220 | {% else %} 221 | 正常 222 | {% endif %} 223 | 225 | {% if not user.is_admin %} 226 |
227 |
231 | {% if user.is_banned %} 232 | 233 | 240 | {% else %} 241 | 242 | 249 | {% endif %} 250 |
251 |
255 | 262 |
263 |
264 | {% else %} 265 | 无操作 266 | {% endif %} 267 |
暂无用户
276 |
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 |
256 |

{{ prompt.title }}

257 | 260 | {% if prompt.is_public %}公开{% else %}私有{% endif %} 261 | 262 |
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 |
136 |
137 |
138 |
139 |
生成新邀请码
140 |
146 | 147 |
148 | 149 | 158 |
159 |
160 | 163 |
164 |
165 |
166 |
167 |
168 |
169 | 170 |
171 |
172 |
173 |
174 |
175 |
176 |
邀请码列表
177 |
178 | 188 |
189 |
190 | 191 | ← 左右滑动查看更多 → 194 |
195 | 196 | 197 | 198 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | {% for code in invite_codes %} 217 | 218 | 228 | 229 | 230 | 231 | 242 | 243 | 244 | 245 | {% else %} 246 | 247 | 248 | 249 | {% endfor %} 250 | 251 |
199 |
200 | 205 |
206 |
邀请码创建者创建时间状态使用者使用时间
219 |
220 | 226 |
227 |
{{ code.code }}{{ code.creator_username }}{{ code.created_at }} 232 | {% if code.is_used %} 233 | 234 | 已使用 235 | 236 | {% else %} 237 | 238 | 可用 239 | 240 | {% endif %} 241 | {{ code.used_by_username or '-' }}{{ code.used_at or '-' }}
暂无邀请码
252 |
253 |
254 |
255 |
256 |
257 |
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 |
267 |
268 |
269 |
270 | {% if profile_user.avatar_url %} 271 | {{ profile_user.username }} 276 | {% else %} 277 | 278 | {% endif %} 279 |
280 |
281 |
282 |
283 |

{{ profile_user.username }}

284 | {% if session.user_id == profile_user.id %} 285 | 289 | 编辑资料 291 | 292 | {% endif %} 293 |
294 |

{{ profile_user.email }}

295 |

296 | 注册于 {% if 297 | profile_user.created_at %} {{ profile_user.created_at }} {% else %} 298 | 未知时间 {% endif %} 299 |

300 | 301 |
302 |
303 |

{{ prompts_count }}

304 |

提示词

305 |
306 |
307 |

{{ likes_count }}

308 |

收藏

309 |
310 |
311 |

{{ views_count }}

312 |

浏览量

313 |
314 |
315 |
316 |
317 |
318 | 319 | 320 |
321 | 收藏的提示词 322 |
323 | 324 | 325 | {% if prompts %} 326 |
327 | {% for prompt in prompts %} 328 |
329 |
332 | 336 | {{ prompt.title }} 337 | 338 | {% if prompt.is_public %} 339 | 公开 340 | {% else %} 341 | 私有 342 | {% endif %} 343 |
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 |
289 |

{{ prompt.title }}

290 | 293 | {% if prompt.is_public %}公开{% else %}私有{% endif %} 294 | 295 |
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 |
340 | 380 |
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 |
100 |
101 |
102 |
103 |

104 | AI 自动填充设置 105 |

106 |

107 | 配置 AI API 以启用自动生成标题、描述和标签功能 108 |

109 |
110 | 111 |
112 |
113 | 114 |
115 |
116 | AI 提供商配置 117 |
118 | 119 |
120 | 121 | 129 |
130 | 131 |
132 | 138 | 147 | {% if config and config.api_key %} 148 |
149 | 150 | 已配置 API Key,留空将保持原 Key 不变 151 |
152 | {% endif %} 153 |
154 | 155 |
156 | 157 | 165 |
166 | 自定义 API 的基础 URL(兼容 OpenAI 格式) 167 |
168 |
169 | 170 |
171 | 172 | 181 |
182 | 例如:gpt-3.5-turbo, gpt-4, gpt-4-turbo 183 |
184 |
185 | 186 |
187 | 190 | 191 |
192 |
193 | 194 | 195 |
196 |
197 | 198 | 高级设置 199 | 207 |
208 | 209 |
210 |
211 | 212 | 222 |
控制输出的随机性,范围 0.0-2.0,默认 0.7
223 |
224 | 225 |
226 | 227 | 236 |
生成内容的最大长度,范围 100-2000,默认 500
237 |
238 |
239 |
240 | 241 | 242 | 243 |
244 |
245 | 252 | 255 |
256 |
257 | 258 | 259 |
260 | 261 | 返回设置 262 | 263 | 266 |
267 |
268 |
269 |
270 |
271 |
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 | --------------------------------------------------------------------------------