feat: add external API service with API Key management
- POST /api/v1/prompt/optimize — core endpoint with X-API-Key auth - Rate limiting: 20 requests per 60s per key - API docs page: /api-docs with cURL/Python/JS examples - Key management: /admin/apikeys — create, toggle, delete keys - ApiKey model with usage tracking (request_count, last_used_at) - Keys stored in DB with env var API_KEYS fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,10 @@ def create_app(config_class=None):
|
|||||||
from src.flask_prompt_master.routes.expert_generate_6 import expert_generate_6_bp
|
from src.flask_prompt_master.routes.expert_generate_6 import expert_generate_6_bp
|
||||||
app.register_blueprint(expert_generate_6_bp)
|
app.register_blueprint(expert_generate_6_bp)
|
||||||
|
|
||||||
|
# 注册对外 API 服务(基于6号专家的提示词优化能力,含鉴权和限流)
|
||||||
|
from src.flask_prompt_master.routes.api_service import api_service_bp
|
||||||
|
app.register_blueprint(api_service_bp)
|
||||||
|
|
||||||
# 注册 Android 工程师专区(Crash 解读、依赖冲突分析等)
|
# 注册 Android 工程师专区(Crash 解读、依赖冲突分析等)
|
||||||
from src.flask_prompt_master.routes.android_tools import android_tools_bp
|
from src.flask_prompt_master.routes.android_tools import android_tools_bp
|
||||||
app.register_blueprint(android_tools_bp)
|
app.register_blueprint(android_tools_bp)
|
||||||
|
|||||||
@@ -165,4 +165,16 @@ class ResumeOptimization(db.Model):
|
|||||||
optimized_content = db.Column(db.Text, nullable=False) # 优化后内容(Markdown)
|
optimized_content = db.Column(db.Text, nullable=False) # 优化后内容(Markdown)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
user = db.relationship('User', backref='resume_optimizations')
|
user = db.relationship('User', backref='resume_optimizations')
|
||||||
|
|
||||||
|
class ApiKey(db.Model):
|
||||||
|
"""API 密钥管理表"""
|
||||||
|
__tablename__ = 'api_key'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
key = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
label = db.Column(db.String(100), nullable=False) # 备注名(如:张三/前端团队)
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
last_used_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
request_count = db.Column(db.Integer, default=0) # 累计调用次数
|
||||||
479
src/flask_prompt_master/routes/api_service.py
Normal file
479
src/flask_prompt_master/routes/api_service.py
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
对外 API 服务 — 基于6号专家的提示词优化能力
|
||||||
|
|
||||||
|
POST /api/v1/prompt/optimize
|
||||||
|
Header: X-API-Key: <key>
|
||||||
|
Body: {"input_text": "..."}
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import wraps
|
||||||
|
from flask import Blueprint, request, jsonify, render_template
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
api_service_bp = Blueprint('api_service', __name__)
|
||||||
|
|
||||||
|
# ===== API 密钥管理 =====
|
||||||
|
# 优先从数据库加载,环境变量 API_KEYS 作为补充
|
||||||
|
_env_keys = os.environ.get('API_KEYS', '')
|
||||||
|
_env_key_set = set(k.strip() for k in _env_keys.split(',') if k.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_keys():
|
||||||
|
"""获取所有活跃的 API 密钥(数据库 + 环境变量)"""
|
||||||
|
keys = set(_env_key_set)
|
||||||
|
try:
|
||||||
|
from src.flask_prompt_master.models.models import ApiKey
|
||||||
|
for k in ApiKey.query.filter_by(is_active=True).all():
|
||||||
|
keys.add(k.key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("从数据库加载 API 密钥失败: %s", e)
|
||||||
|
if not keys:
|
||||||
|
keys.add('sk-test-demo-key')
|
||||||
|
logger.warning("未配置任何 API 密钥,使用默认测试密钥: sk-test-demo-key")
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _record_usage(api_key):
|
||||||
|
"""记录 API 密钥使用"""
|
||||||
|
try:
|
||||||
|
from src.flask_prompt_master.models.models import ApiKey
|
||||||
|
from src.flask_prompt_master import db
|
||||||
|
k = ApiKey.query.filter_by(key=api_key, is_active=True).first()
|
||||||
|
if k:
|
||||||
|
k.request_count = (k.request_count or 0) + 1
|
||||||
|
k.last_used_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ===== 限流 =====
|
||||||
|
_rate_window = 60 # 窗口(秒)
|
||||||
|
_rate_limit = 20 # 每窗口最大请求数
|
||||||
|
_rate_buckets = {} # {api_key: [(timestamp,), ...]}
|
||||||
|
|
||||||
|
# ===== LLM 客户端 =====
|
||||||
|
client = OpenAI(
|
||||||
|
api_key=os.environ.get('LLM_API_KEY') or 'sk-fdf7cc1c73504e628ec0119b7e11b8cc',
|
||||||
|
base_url=os.environ.get('LLM_API_URL') or 'https://api.deepseek.com/v1'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 阶段1:消歧意图分析
|
||||||
|
INTENT_PROMPT_V4 = """你是一位资深的意图分析专家。你的任务是精确理解用户需求,尤其要处理中文多义词的歧义。
|
||||||
|
|
||||||
|
## 消歧规则(优先级从高到低)
|
||||||
|
|
||||||
|
### 规则1:"设计"的多义性
|
||||||
|
| 上下文 | 判定 |
|
||||||
|
|--------|------|
|
||||||
|
| 系统设计、架构设计、数据库设计、API设计、模块设计、技术方案设计 | **技术** |
|
||||||
|
| UI设计、海报设计、品牌设计、视觉设计、创意设计、艺术设计 | **创意** |
|
||||||
|
| 流程设计、组织设计、商业模式设计、制度设计 | **咨询** |
|
||||||
|
|
||||||
|
### 规则2:"方案"的多义性
|
||||||
|
| 上下文 | 判定 |
|
||||||
|
|--------|------|
|
||||||
|
| 技术方案、架构方案、实施方案、部署方案 | **技术** |
|
||||||
|
| 营销方案、活动方案、传播方案、内容方案 | **创意** |
|
||||||
|
| 管理方案、策略方案、优化方案、治理方案 | **咨询** |
|
||||||
|
|
||||||
|
### 规则3:"分析"的多义性
|
||||||
|
| 上下文 | 判定 |
|
||||||
|
|--------|------|
|
||||||
|
| 数据分析、日志分析、性能分析、安全分析 | **分析** |
|
||||||
|
| 需求分析、竞品分析、市场分析 | **咨询** |
|
||||||
|
| 色彩分析、构图分析、文案分析 | **创意** |
|
||||||
|
|
||||||
|
### 规则4:技术关键词强信号
|
||||||
|
看到以下词时,优先判为技术:
|
||||||
|
Agent、API、SDK、架构、微服务、数据库、后端、前端、部署、DevOps、容器化、分布式、协议、接口、中间件、算法、模型训练、推理、向量、RAG、Pipeline、CI/CD、Kubernetes、Docker、Git
|
||||||
|
|
||||||
|
## 判定流程
|
||||||
|
1. 提取核心名词+动词组合
|
||||||
|
2. 查询上述消歧表
|
||||||
|
3. 判断用户最终要产出什么
|
||||||
|
4. 给出判定 + 置信度
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
严格返回以下JSON,不要任何额外内容:
|
||||||
|
{
|
||||||
|
"core_intent": "技术",
|
||||||
|
"sub_category": "多Agent协作系统设计",
|
||||||
|
"domain": "二级精确领域",
|
||||||
|
"confidence": 0.92,
|
||||||
|
"disambiguation_note": "判定依据简述",
|
||||||
|
"alternative_intent": "咨询",
|
||||||
|
"key_requirements": ["需求1", "需求2", "需求3"],
|
||||||
|
"expected_output": "精确的预期产出描述",
|
||||||
|
"constraints": ["约束1", "约束2"],
|
||||||
|
"keywords": ["关键词1", "关键词2", "关键词3"]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 阶段2:生成优化提示词
|
||||||
|
PROMPT_GENERATOR_TEMPLATES = {
|
||||||
|
"技术": """你是一位资深提示词工程师,专精于技术领域。你的任务是生成一个高质量的提示词,让下游LLM能够输出专业的技术方案。
|
||||||
|
|
||||||
|
意图分析:
|
||||||
|
{analysis}
|
||||||
|
|
||||||
|
请生成一个完整的提示词,必须包含以下要素:
|
||||||
|
1. **角色设定**:明确LLM扮演什么技术专家角色(架构师/开发者/技术顾问),给出具体的专业背景
|
||||||
|
2. **任务描述**:清晰陈述要完成的技术任务,拆解为2-4个可执行步骤
|
||||||
|
3. **输出结构**:定义输出的章节和每章要包含的具体内容(不要只列标题,要说明每章怎么写)
|
||||||
|
4. **质量标准**:给出具体的质量要求(如"每个API必须给出字段级规范"而非"详细描述")
|
||||||
|
5. **约束条件**:明确禁止什么、必须包含什么
|
||||||
|
|
||||||
|
关键原则:
|
||||||
|
- 你生成的是提示词,不是最终答案
|
||||||
|
- 提示词必须自包含——用户复制后可以直接在任何LLM中使用
|
||||||
|
- 使用具体的技术术语和可验证的标准
|
||||||
|
- 每个章节指引都应该是"如何写"而非"写什么"
|
||||||
|
|
||||||
|
请在末尾附上一句给用户的简短说明(用「」括起来),解释这个提示词适合什么场景使用。""",
|
||||||
|
|
||||||
|
"创意": """你是一位资深提示词工程师,专精于创意领域。你的任务是生成一个高质量的提示词,让下游LLM能够输出专业的创意方案。
|
||||||
|
|
||||||
|
意图分析:
|
||||||
|
{analysis}
|
||||||
|
|
||||||
|
请生成一个完整的提示词,必须包含以下要素:
|
||||||
|
1. **角色设定**:明确LLM扮演什么创意角色(创意总监/文案策划/品牌顾问),给出具体的风格偏好
|
||||||
|
2. **任务描述**:清晰陈述创意任务,包括目标受众、品牌调性、传播目标
|
||||||
|
3. **输出结构**:定义输出的章节和每章要包含的具体内容——给出框架但不能限制创意发挥
|
||||||
|
4. **质量标准**:给出具体的创意质量要求(如"每个视觉描述必须包含色彩/形状/情绪三个维度")
|
||||||
|
5. **灵感框架**:提供1-2个创作维度的引导,但不预设具体答案
|
||||||
|
|
||||||
|
关键原则:
|
||||||
|
- 你生成的是提示词,不是最终文案/设计稿
|
||||||
|
- 提示词必须引导LLM发挥创意,同时给出足够结构约束避免空泛
|
||||||
|
- 拒绝"高端大气上档次"这类空话——给出可触摸的质量标准
|
||||||
|
|
||||||
|
请在末尾附上一句给用户的简短说明(用「」括起来),解释这个提示词适合什么场景使用。""",
|
||||||
|
|
||||||
|
"分析": """你是一位资深提示词工程师,专精于数据分析领域。你的任务是生成一个高质量的提示词,让下游LLM能够输出专业的数据分析报告。
|
||||||
|
|
||||||
|
意图分析:
|
||||||
|
{analysis}
|
||||||
|
|
||||||
|
请生成一个完整的提示词,必须包含以下要素:
|
||||||
|
1. **角色设定**:明确LLM扮演什么分析角色(数据分析师/商业分析师/研究员),给出分析方法论倾向
|
||||||
|
2. **任务描述**:清晰陈述分析目标、数据范围、要回答的核心问题
|
||||||
|
3. **输出结构**:定义输出的章节——包括分析框架、指标定义、可视化建议、洞察和局限性
|
||||||
|
4. **质量标准**:给出具体的分析质量标准(如"所有结论必须附数据支撑""指标必须给出计算口径")
|
||||||
|
5. **方法论指引**:建议使用的分析思维(MECE/假设驱动/探索式),但不强制
|
||||||
|
|
||||||
|
关键原则:
|
||||||
|
- 你生成的是提示词,不是最终分析报告
|
||||||
|
- 提示词必须要求LLM展示推理过程,而非只给结论
|
||||||
|
- 要求LLM诚实标注数据局限性和置信度
|
||||||
|
|
||||||
|
请在末尾附上一句给用户的简短说明(用「」括起来),解释这个提示词适合什么场景使用。""",
|
||||||
|
|
||||||
|
"咨询": """你是一位资深提示词工程师,专精于管理咨询领域。你的任务是生成一个高质量的提示词,让下游LLM能够输出专业的咨询建议。
|
||||||
|
|
||||||
|
意图分析:
|
||||||
|
{analysis}
|
||||||
|
|
||||||
|
请生成一个完整的提示词,必须包含以下要素:
|
||||||
|
1. **角色设定**:明确LLM扮演什么咨询角色(管理顾问/策略顾问/组织顾问),给出咨询方法论倾向
|
||||||
|
2. **任务描述**:清晰陈述咨询问题、客户背景、决策情境
|
||||||
|
3. **输出结构**:定义输出的章节——诊断→方案矩阵→推荐→路线图→风险→ROI
|
||||||
|
4. **质量标准**:给出具体的咨询质量标准(如"方案必须可比较""风险必须有概率×影响评估")
|
||||||
|
5. **决策导向**:提示词应引导LLM最终输出可执行的决策建议,而非泛泛分析
|
||||||
|
|
||||||
|
关键原则:
|
||||||
|
- 你生成的是提示词,不是最终咨询报告
|
||||||
|
- 提示词必须要求LLM给出带权重的选项,而非单一建议
|
||||||
|
- 要求LLM明确标注假设前提
|
||||||
|
|
||||||
|
请在末尾附上一句给用户的简短说明(用「」括起来),解释这个提示词适合什么场景使用。"""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(api_key):
|
||||||
|
"""简单滑动窗口限流,返回 (是否允许, 剩余次数)"""
|
||||||
|
now = time.time()
|
||||||
|
window = _rate_window
|
||||||
|
limit = _rate_limit
|
||||||
|
|
||||||
|
bucket = _rate_buckets.setdefault(api_key, [])
|
||||||
|
# 清理过期记录
|
||||||
|
bucket[:] = [ts for ts in bucket if now - ts < window]
|
||||||
|
|
||||||
|
if len(bucket) >= limit:
|
||||||
|
return False, 0
|
||||||
|
|
||||||
|
bucket.append(now)
|
||||||
|
# 定期清理旧 bucket 防止内存泄漏
|
||||||
|
if len(_rate_buckets) > 1000:
|
||||||
|
_rate_buckets.clear()
|
||||||
|
return True, limit - len(bucket)
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(f):
|
||||||
|
"""API Key 鉴权装饰器"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({'code': 401, 'message': '缺少 X-API-Key,请提供 API 密钥', 'data': None}), 401
|
||||||
|
if api_key not in _get_active_keys():
|
||||||
|
return jsonify({'code': 403, 'message': '无效的 API 密钥', 'data': None}), 403
|
||||||
|
|
||||||
|
allowed, remaining = _check_rate_limit(api_key)
|
||||||
|
if not allowed:
|
||||||
|
return jsonify({
|
||||||
|
'code': 429,
|
||||||
|
'message': f'请求过于频繁,每{_rate_window}秒限{_rate_limit}次',
|
||||||
|
'data': None
|
||||||
|
}), 429
|
||||||
|
|
||||||
|
_record_usage(api_key)
|
||||||
|
|
||||||
|
resp = f(*args, **kwargs)
|
||||||
|
if isinstance(resp, tuple):
|
||||||
|
body, status = resp
|
||||||
|
else:
|
||||||
|
body, status = resp, 200
|
||||||
|
|
||||||
|
data = body.get_json() if hasattr(body, 'get_json') else body
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data.setdefault('rate_limit', {
|
||||||
|
'remaining': remaining,
|
||||||
|
'limit': _rate_limit,
|
||||||
|
'window_seconds': _rate_window
|
||||||
|
})
|
||||||
|
return jsonify(data), status
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pipeline(input_text):
|
||||||
|
"""两阶段提示词优化管线,返回 (intent_data, optimized_prompt) 或抛出异常"""
|
||||||
|
# 阶段1
|
||||||
|
resp1 = client.chat.completions.create(
|
||||||
|
model="deepseek-chat",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": INTENT_PROMPT_V4},
|
||||||
|
{"role": "user", "content": input_text}
|
||||||
|
],
|
||||||
|
temperature=0.05,
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
intent_raw = (resp1.choices[0].message.content or "").strip()
|
||||||
|
intent_raw = intent_raw.replace('```json', '').replace('```', '').strip()
|
||||||
|
|
||||||
|
intent_data = json.loads(intent_raw)
|
||||||
|
for f in ['core_intent', 'domain', 'key_requirements', 'expected_output', 'constraints', 'keywords']:
|
||||||
|
if f not in intent_data:
|
||||||
|
raise ValueError(f"缺少字段: {f}")
|
||||||
|
if intent_data['core_intent'] not in ('技术', '创意', '分析', '咨询'):
|
||||||
|
intent_data['core_intent'] = '技术'
|
||||||
|
for arr_f in ['key_requirements', 'constraints', 'keywords']:
|
||||||
|
v = intent_data.get(arr_f)
|
||||||
|
if not isinstance(v, list) or len(v) == 0:
|
||||||
|
intent_data[arr_f] = ['未指定']
|
||||||
|
intent_data.setdefault('sub_category', intent_data['domain'])
|
||||||
|
intent_data.setdefault('confidence', 0.75)
|
||||||
|
intent_data.setdefault('disambiguation_note', '')
|
||||||
|
intent_data.setdefault('alternative_intent', '')
|
||||||
|
|
||||||
|
# 阶段2
|
||||||
|
core_intent = intent_data['core_intent']
|
||||||
|
meta_prompt = PROMPT_GENERATOR_TEMPLATES[core_intent]
|
||||||
|
analysis_str = json.dumps(intent_data, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
resp2 = client.chat.completions.create(
|
||||||
|
model="deepseek-chat",
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": meta_prompt.format(analysis=analysis_str)},
|
||||||
|
{"role": "user", "content": f"用户需求:{input_text}\n\n请基于上述意图分析,生成一个高质量的提示词。"}
|
||||||
|
],
|
||||||
|
temperature=0.5,
|
||||||
|
max_tokens=1500,
|
||||||
|
timeout=90
|
||||||
|
)
|
||||||
|
optimized_prompt = (resp2.choices[0].message.content or "").strip()
|
||||||
|
if not optimized_prompt:
|
||||||
|
raise RuntimeError("提示词生成失败")
|
||||||
|
|
||||||
|
return intent_data, optimized_prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ===== API 路由 =====
|
||||||
|
|
||||||
|
@api_service_bp.route('/api/v1/prompt/optimize', methods=['POST'])
|
||||||
|
@require_api_key
|
||||||
|
def optimize_prompt():
|
||||||
|
"""
|
||||||
|
优化提示词 API
|
||||||
|
POST /api/v1/prompt/optimize
|
||||||
|
Header: X-API-Key: <key>
|
||||||
|
Body: {"input_text": "需求描述或原始提示词"}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({'code': 400, 'message': '请求必须是 JSON 格式', 'data': None}), 400
|
||||||
|
|
||||||
|
payload = request.get_json() or {}
|
||||||
|
input_text = (payload.get('input_text') or '').strip()
|
||||||
|
if not input_text:
|
||||||
|
return jsonify({'code': 400, 'message': '缺少 input_text 字段', 'data': None}), 400
|
||||||
|
if len(input_text) < 10:
|
||||||
|
return jsonify({'code': 400, 'message': 'input_text 至少需要 10 个字符', 'data': None}), 400
|
||||||
|
|
||||||
|
intent_data, optimized_prompt = _run_pipeline(input_text)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'message': 'success',
|
||||||
|
'data': {
|
||||||
|
'intent_analysis': intent_data,
|
||||||
|
'optimized_prompt': optimized_prompt,
|
||||||
|
'usage': {
|
||||||
|
'api_calls': 2,
|
||||||
|
'prompt_length': len(optimized_prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.exception("API JSON解析失败")
|
||||||
|
return jsonify({'code': 500, 'message': '意图分析格式有误,请重试', 'data': None}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("API 生成失败")
|
||||||
|
return jsonify({'code': 500, 'message': str(e) or '服务内部错误', 'data': None}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_service_bp.route('/api/v1/prompt/optimize', methods=['GET'])
|
||||||
|
def api_info():
|
||||||
|
"""API 信息"""
|
||||||
|
return jsonify({
|
||||||
|
'service': '提示词优化 API v1',
|
||||||
|
'endpoint': '/api/v1/prompt/optimize',
|
||||||
|
'method': 'POST',
|
||||||
|
'auth': 'Header: X-API-Key',
|
||||||
|
'body': {'input_text': '需求描述或原始提示词(至少10字符)'},
|
||||||
|
'response': {
|
||||||
|
'code': 200,
|
||||||
|
'data': {
|
||||||
|
'intent_analysis': '消歧分析结果',
|
||||||
|
'optimized_prompt': '优化后的提示词'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'rate_limit': f'每{_rate_window}秒 {_rate_limit} 次/密钥',
|
||||||
|
'docs': '/api-docs'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_service_bp.route('/api-docs', methods=['GET'])
|
||||||
|
def api_docs_page():
|
||||||
|
"""API 文档页面"""
|
||||||
|
return render_template('api_docs.html')
|
||||||
|
|
||||||
|
|
||||||
|
# ===== API Key 管理页面 =====
|
||||||
|
|
||||||
|
@api_service_bp.route('/admin/apikeys', methods=['GET'])
|
||||||
|
def apikey_manage_page():
|
||||||
|
"""API 密钥管理页面"""
|
||||||
|
return render_template('apikey_manage.html')
|
||||||
|
|
||||||
|
|
||||||
|
@api_service_bp.route('/api/admin/apikeys', methods=['GET'])
|
||||||
|
def apikey_list():
|
||||||
|
"""获取所有密钥"""
|
||||||
|
try:
|
||||||
|
from src.flask_prompt_master.models.models import ApiKey
|
||||||
|
keys = ApiKey.query.order_by(ApiKey.created_at.desc()).all()
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'data': [{
|
||||||
|
'id': k.id,
|
||||||
|
'label': k.label,
|
||||||
|
'key': k.key[:8] + '...' + k.key[-4:], # 只展示部分
|
||||||
|
'is_active': k.is_active,
|
||||||
|
'created_at': k.created_at.strftime('%Y-%m-%d %H:%M') if k.created_at else '',
|
||||||
|
'last_used_at': k.last_used_at.strftime('%Y-%m-%d %H:%M') if k.last_used_at else '从未使用',
|
||||||
|
'request_count': k.request_count or 0
|
||||||
|
} for k in keys]
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("获取密钥列表失败")
|
||||||
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@api_service_bp.route('/api/admin/apikeys', methods=['POST'])
|
||||||
|
def apikey_create():
|
||||||
|
"""创建新密钥"""
|
||||||
|
try:
|
||||||
|
from src.flask_prompt_master.models.models import ApiKey
|
||||||
|
from src.flask_prompt_master import db
|
||||||
|
|
||||||
|
payload = request.get_json() or {}
|
||||||
|
label = (payload.get('label') or '').strip() or '未备注'
|
||||||
|
|
||||||
|
# 生成随机 key
|
||||||
|
key = 'sk-' + secrets.token_hex(24)
|
||||||
|
|
||||||
|
k = ApiKey(key=key, label=label)
|
||||||
|
db.session.add(k)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info("创建 API Key: %s (%s)", key[:12] + '...', label)
|
||||||
|
return jsonify({
|
||||||
|
'code': 200,
|
||||||
|
'message': '创建成功',
|
||||||
|
'data': {'id': k.id, 'label': k.label, 'key': key}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("创建密钥失败")
|
||||||
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@api_service_bp.route('/api/admin/apikeys/<int:key_id>/toggle', methods=['POST'])
|
||||||
|
def apikey_toggle(key_id):
|
||||||
|
"""启用/禁用密钥"""
|
||||||
|
try:
|
||||||
|
from src.flask_prompt_master.models.models import ApiKey
|
||||||
|
from src.flask_prompt_master import db
|
||||||
|
|
||||||
|
k = ApiKey.query.get(key_id)
|
||||||
|
if not k:
|
||||||
|
return jsonify({'code': 404, 'message': '密钥不存在'})
|
||||||
|
|
||||||
|
k.is_active = not k.is_active
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
action = '启用' if k.is_active else '禁用'
|
||||||
|
logger.info("%s API Key: %s (%s)", action, k.key[:12] + '...', k.label)
|
||||||
|
return jsonify({'code': 200, 'message': f'已{action}', 'data': {'is_active': k.is_active}})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@api_service_bp.route('/api/admin/apikeys/<int:key_id>', methods=['DELETE'])
|
||||||
|
def apikey_delete(key_id):
|
||||||
|
"""删除密钥"""
|
||||||
|
try:
|
||||||
|
from src.flask_prompt_master.models.models import ApiKey
|
||||||
|
from src.flask_prompt_master import db
|
||||||
|
|
||||||
|
k = ApiKey.query.get(key_id)
|
||||||
|
if not k:
|
||||||
|
return jsonify({'code': 404, 'message': '密钥不存在'})
|
||||||
|
|
||||||
|
db.session.delete(k)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info("删除 API Key: %s", k.key[:12] + '...')
|
||||||
|
return jsonify({'code': 200, 'message': '已删除'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'code': 500, 'message': str(e)})
|
||||||
199
src/flask_prompt_master/templates/api_docs.html
Normal file
199
src/flask_prompt_master/templates/api_docs.html
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}API 文档 — 提示词优化服务{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="docs-layout">
|
||||||
|
<div class="docs-container">
|
||||||
|
<div class="docs-header">
|
||||||
|
<h1><i class="fas fa-code"></i> 提示词优化 API</h1>
|
||||||
|
<p>基于6号专家的两阶段管线,将需求描述或粗糙提示词转化为高质量、可复用的优化提示词。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h2>接口信息</h2>
|
||||||
|
<div class="endpoint-card">
|
||||||
|
<div class="method-badge post">POST</div>
|
||||||
|
<code class="endpoint-url">/api/v1/prompt/optimize</code>
|
||||||
|
<p class="endpoint-desc">提交输入文本,返回消歧分析结果和优化后的提示词</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h2>认证方式</h2>
|
||||||
|
<p>在请求头中携带 <code>X-API-Key</code>:</p>
|
||||||
|
<pre class="code-block"><code>X-API-Key: sk-your-api-key</code></pre>
|
||||||
|
<p>联系管理员获取 API 密钥。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h2>请求示例</h2>
|
||||||
|
<h3>cURL</h3>
|
||||||
|
<pre class="code-block"><code>curl -X POST {{ request.host_url }}api/v1/prompt/optimize \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: sk-your-api-key" \
|
||||||
|
-d '{"input_text": "写一个Python数据分析脚本,处理销售CSV并生成月度汇总报告"}'</code></pre>
|
||||||
|
|
||||||
|
<h3>Python</h3>
|
||||||
|
<pre class="code-block"><code>import requests
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
"{{ request.host_url }}api/v1/prompt/optimize",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
json={"input_text": "写一篇关于agent协助的设计方案"}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = resp.json()
|
||||||
|
print(result["data"]["optimized_prompt"])</code></pre>
|
||||||
|
|
||||||
|
<h3>JavaScript</h3>
|
||||||
|
<pre class="code-block"><code>const resp = await fetch("{{ request.host_url }}api/v1/prompt/optimize", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": "sk-your-api-key"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ input_text: "设计一个SaaS官网的UI/UX方案" })
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await resp.json();
|
||||||
|
console.log(data.optimized_prompt);</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h2>请求参数</h2>
|
||||||
|
<table class="param-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>input_text</td><td>string</td><td>是</td><td>需求描述或原始提示词,至少10个字符</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h2>响应格式</h2>
|
||||||
|
<pre class="code-block"><code>{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"intent_analysis": {
|
||||||
|
"core_intent": "技术",
|
||||||
|
"sub_category": "Agent协作系统设计",
|
||||||
|
"domain": "人工智能与智能体系统",
|
||||||
|
"confidence": 0.95,
|
||||||
|
"disambiguation_note": "判定依据",
|
||||||
|
"alternative_intent": "咨询",
|
||||||
|
"key_requirements": ["需求1", "需求2"],
|
||||||
|
"expected_output": "预期产出描述",
|
||||||
|
"constraints": ["约束1"],
|
||||||
|
"keywords": ["agent", "协作", "设计方案"]
|
||||||
|
},
|
||||||
|
"optimized_prompt": "# 优化后的完整提示词...",
|
||||||
|
"usage": {
|
||||||
|
"api_calls": 2,
|
||||||
|
"prompt_length": 1785
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rate_limit": {
|
||||||
|
"remaining": 19,
|
||||||
|
"limit": 20,
|
||||||
|
"window_seconds": 60
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h3>字段说明</h3>
|
||||||
|
<table class="param-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>字段</th><th>说明</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>intent_analysis.core_intent</code></td><td>核心意图分类:技术 / 创意 / 分析 / 咨询</td></tr>
|
||||||
|
<tr><td><code>intent_analysis.confidence</code></td><td>置信度 0-1,≥0.85 为高置信</td></tr>
|
||||||
|
<tr><td><code>intent_analysis.disambiguation_note</code></td><td>消歧判定依据</td></tr>
|
||||||
|
<tr><td><code>optimized_prompt</code></td><td>优化后的提示词,可直接复制到任意 LLM 使用</td></tr>
|
||||||
|
<tr><td><code>usage.api_calls</code></td><td>本次消耗的 API 调用次数(固定 2)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h2>限流</h2>
|
||||||
|
<p>每密钥每60秒最多 <strong>20</strong> 次请求。响应头中会返回限流状态:</p>
|
||||||
|
<pre class="code-block"><code>"rate_limit": {
|
||||||
|
"remaining": 19,
|
||||||
|
"limit": 20,
|
||||||
|
"window_seconds": 60
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h2>错误码</h2>
|
||||||
|
<table class="param-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>状态码</th><th>说明</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>200</td><td>成功</td></tr>
|
||||||
|
<tr><td>400</td><td>参数错误(缺少 input_text / 长度不足 / 非 JSON)</td></tr>
|
||||||
|
<tr><td>401</td><td>缺少 X-API-Key</td></tr>
|
||||||
|
<tr><td>403</td><td>无效的 API 密钥</td></tr>
|
||||||
|
<tr><td>429</td><td>触发限流</td></tr>
|
||||||
|
<tr><td>500</td><td>服务内部错误</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.docs-layout { min-height: calc(100vh - 120px); padding: 2rem 1.5rem; background: #f8fafc; }
|
||||||
|
.docs-container { max-width: 860px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.docs-header { margin-bottom: 2rem; }
|
||||||
|
.docs-header h1 { font-size: 2rem; font-weight: 700; display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.docs-header h1 i { color: #06b6d4; }
|
||||||
|
.docs-header p { color: var(--text-light); font-size: 0.95rem; margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.docs-section { margin-bottom: 2rem; }
|
||||||
|
.docs-section h2 {
|
||||||
|
font-size: 1.2rem; font-weight: 600; margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.docs-section h3 { font-size: 0.95rem; font-weight: 600; margin: 1rem 0 0.5rem; }
|
||||||
|
|
||||||
|
.endpoint-card {
|
||||||
|
background: white; border-radius: 10px; padding: 1.25rem;
|
||||||
|
border: 1px solid var(--border-color); display: flex; align-items: center; gap: 1rem;
|
||||||
|
}
|
||||||
|
.method-badge {
|
||||||
|
padding: 0.25rem 0.75rem; border-radius: 4px;
|
||||||
|
font-weight: 700; font-size: 0.8rem; color: white; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.method-badge.post { background: #10b981; }
|
||||||
|
.endpoint-url { font-size: 1.1rem; font-weight: 600; color: var(--text-color); }
|
||||||
|
.endpoint-desc { color: var(--text-light); font-size: 0.85rem; margin: 0; }
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: #1e293b; color: #e2e8f0; border-radius: 10px; padding: 1rem 1.25rem;
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.85rem;
|
||||||
|
line-height: 1.6; overflow-x: auto; white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-table { width: 100%; border-collapse: collapse; background: white; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-color); }
|
||||||
|
.param-table th { background: #f1f5f9; padding: 0.75rem 1rem; text-align: left; font-size: 0.85rem; font-weight: 600; }
|
||||||
|
.param-table td { padding: 0.75rem 1rem; border-top: 1px solid var(--border-color); font-size: 0.9rem; }
|
||||||
|
.param-table code { background: #f1f5f9; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.docs-layout { padding: 1rem; }
|
||||||
|
.endpoint-card { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
258
src/flask_prompt_master/templates/apikey_manage.html
Normal file
258
src/flask_prompt_master/templates/apikey_manage.html
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}API Key 管理{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1><i class="fas fa-key"></i> API Key 管理</h1>
|
||||||
|
<p>为外部调用者创建和管理 API 密钥</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn-create" id="createBtn">
|
||||||
|
<i class="fas fa-plus"></i> 创建新密钥
|
||||||
|
</button>
|
||||||
|
<span class="hint">创建后将一次性展示完整密钥,请提醒对方保存</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="keysTable" class="table-wrap">
|
||||||
|
<div class="loading-text">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建弹窗 -->
|
||||||
|
<div class="modal-overlay" id="createModal" style="display:none;">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3>创建 API Key</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="keyLabel">备注名</label>
|
||||||
|
<input type="text" id="keyLabel" placeholder="例如:张三、前端团队、测试环境">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-cancel" id="modalCancel">取消</button>
|
||||||
|
<button class="btn-confirm" id="modalConfirm">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密钥展示弹窗 -->
|
||||||
|
<div class="modal-overlay" id="showModal" style="display:none;">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3><i class="fas fa-check-circle" style="color:#10b981;"></i> 密钥已创建</h3>
|
||||||
|
<p class="warn-text">以下密钥只展示一次,请立即复制</p>
|
||||||
|
<div class="key-display" id="newKeyDisplay"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-copy-key" id="copyKeyBtn"><i class="fas fa-copy"></i> 复制</button>
|
||||||
|
<button class="btn-cancel" id="showClose">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.layout { min-height: calc(100vh - 120px); padding: 2rem 1.5rem; background: #f8fafc; }
|
||||||
|
.container { max-width: 860px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.header { margin-bottom: 1.5rem; }
|
||||||
|
.header h1 { font-size: 1.6rem; font-weight: 700; display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.header p { color: var(--text-light); margin-top: 0.25rem; }
|
||||||
|
|
||||||
|
.toolbar { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.btn-create {
|
||||||
|
padding: 0.6rem 1.25rem; border: none; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #06b6d4, #3b82f6); color: white;
|
||||||
|
font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.btn-create:hover { transform: translateY(-1px); }
|
||||||
|
.hint { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.table-wrap { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||||
|
.loading-text { text-align: center; padding: 3rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { background: #f1f5f9; padding: 0.75rem 1rem; text-align: left; font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
td { padding: 0.75rem 1rem; border-top: 1px solid var(--border-color); font-size: 0.9rem; }
|
||||||
|
tr:hover td { background: #f8fafc; }
|
||||||
|
|
||||||
|
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; font-weight: 500; }
|
||||||
|
.badge-active { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-inactive { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.6rem; border-radius: 6px; border: 1px solid var(--border-color);
|
||||||
|
background: white; cursor: pointer; font-size: 0.78rem; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { background: #f1f5f9; }
|
||||||
|
.btn-sm.danger { color: #ef4444; border-color: #fecaca; }
|
||||||
|
.btn-sm.danger:hover { background: #fef2f2; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 999; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.modal-card { background: white; border-radius: 14px; padding: 2rem; width: 420px; max-width: 90vw; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
||||||
|
.modal-card h3 { margin: 0 0 1rem 0; font-size: 1.1rem; }
|
||||||
|
.form-group { margin-bottom: 1.25rem; }
|
||||||
|
.form-group label { display: block; font-weight: 600; font-size: 0.85rem; margin-bottom: 0.4rem; }
|
||||||
|
.form-group input {
|
||||||
|
width: 100%; padding: 0.6rem 0.75rem; border: 1px solid var(--border-color); border-radius: 8px; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form-group input:focus { outline: none; border-color: #06b6d4; }
|
||||||
|
|
||||||
|
.modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
|
||||||
|
.btn-cancel { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid var(--border-color); background: white; cursor: pointer; }
|
||||||
|
.btn-confirm { padding: 0.5rem 1.25rem; border-radius: 8px; border: none; background: linear-gradient(135deg, #06b6d4, #3b82f6); color: white; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn-confirm:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.warn-text { color: #f59e0b; font-weight: 500; font-size: 0.85rem; margin: 0 0 0.75rem 0; }
|
||||||
|
.key-display {
|
||||||
|
background: #1e293b; color: #10b981; border-radius: 10px; padding: 1rem;
|
||||||
|
font-family: 'SF Mono', 'Monaco', monospace; font-size: 0.95rem;
|
||||||
|
word-break: break-all; margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.btn-copy-key {
|
||||||
|
padding: 0.5rem 1rem; border-radius: 8px; border: none;
|
||||||
|
background: #10b981; color: white; font-weight: 600; cursor: pointer;
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row { text-align: center; padding: 3rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.toast-container { position: fixed; top: 1rem; right: 1rem; z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.toast { padding: 0.75rem 1.25rem; border-radius: 8px; color: white; font-size: 0.875rem; font-weight: 500; animation: slideIn 0.3s ease; }
|
||||||
|
.toast.success { background: #10b981; }
|
||||||
|
.toast.error { background: #ef4444; }
|
||||||
|
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var createBtn = document.getElementById('createBtn');
|
||||||
|
var createModal = document.getElementById('createModal');
|
||||||
|
var showModal = document.getElementById('showModal');
|
||||||
|
var createdKey = null;
|
||||||
|
|
||||||
|
function showToast(msg, type) {
|
||||||
|
var c = document.getElementById('toastContainer');
|
||||||
|
var t = document.createElement('div');
|
||||||
|
t.className = 'toast ' + type;
|
||||||
|
t.textContent = msg;
|
||||||
|
c.appendChild(t);
|
||||||
|
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'all 0.3s ease'; setTimeout(function() { t.remove(); }, 300); }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKeys() {
|
||||||
|
fetch('/api/admin/apikeys')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(resp) {
|
||||||
|
var wrap = document.getElementById('keysTable');
|
||||||
|
if (resp.code !== 200) { wrap.innerHTML = '<div class="loading-text">加载失败</div>'; return; }
|
||||||
|
var keys = resp.data;
|
||||||
|
if (!keys.length) {
|
||||||
|
wrap.innerHTML = '<div class="empty-row"><i class="fas fa-inbox" style="font-size:2rem;display:block;margin-bottom:0.5rem;"></i>还没有密钥,点击上方按钮创建</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<table><thead><tr><th>备注</th><th>密钥</th><th>状态</th><th>调用次数</th><th>最后使用</th><th>创建时间</th><th>操作</th></tr></thead><tbody>';
|
||||||
|
keys.forEach(function(k) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td><strong>' + escapeHtml(k.label) + '</strong></td>';
|
||||||
|
html += '<td><code>' + k.key + '</code></td>';
|
||||||
|
html += '<td><span class="badge ' + (k.is_active ? 'badge-active' : 'badge-inactive') + '">' + (k.is_active ? '启用' : '禁用') + '</span></td>';
|
||||||
|
html += '<td>' + k.request_count + '</td>';
|
||||||
|
html += '<td>' + k.last_used_at + '</td>';
|
||||||
|
html += '<td>' + k.created_at + '</td>';
|
||||||
|
html += '<td>';
|
||||||
|
html += '<button class="btn-sm" onclick="toggleKey(' + k.id + ')">' + (k.is_active ? '禁用' : '启用') + '</button> ';
|
||||||
|
html += '<button class="btn-sm danger" onclick="deleteKey(' + k.id + ')">删除</button>';
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create modal
|
||||||
|
createBtn.addEventListener('click', function() {
|
||||||
|
document.getElementById('keyLabel').value = '';
|
||||||
|
createModal.style.display = '';
|
||||||
|
});
|
||||||
|
document.getElementById('modalCancel').addEventListener('click', function() {
|
||||||
|
createModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('modalConfirm').addEventListener('click', function() {
|
||||||
|
var label = document.getElementById('keyLabel').value.trim() || '未备注';
|
||||||
|
var btn = this;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '创建中...';
|
||||||
|
|
||||||
|
fetch('/api/admin/apikeys', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label: label })
|
||||||
|
}).then(function(r) { return r.json(); })
|
||||||
|
.then(function(resp) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '创建';
|
||||||
|
if (resp.code === 200) {
|
||||||
|
createModal.style.display = 'none';
|
||||||
|
document.getElementById('newKeyDisplay').textContent = resp.data.key;
|
||||||
|
createdKey = resp.data;
|
||||||
|
showModal.style.display = '';
|
||||||
|
} else {
|
||||||
|
showToast(resp.message || '创建失败', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('showClose').addEventListener('click', function() {
|
||||||
|
showModal.style.display = 'none';
|
||||||
|
loadKeys();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy key
|
||||||
|
document.getElementById('copyKeyBtn').addEventListener('click', function() {
|
||||||
|
if (!createdKey) return;
|
||||||
|
navigator.clipboard.writeText(createdKey.key).then(function() {
|
||||||
|
showToast('已复制到剪贴板', 'success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle
|
||||||
|
window.toggleKey = function(id) {
|
||||||
|
fetch('/api/admin/apikeys/' + id + '/toggle', { method: 'POST' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(resp) {
|
||||||
|
if (resp.code === 200) { showToast(resp.message, 'success'); loadKeys(); }
|
||||||
|
else { showToast(resp.message, 'error'); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
window.deleteKey = function(id) {
|
||||||
|
if (!confirm('确定删除此密钥?删除后立即失效。')) return;
|
||||||
|
fetch('/api/admin/apikeys/' + id, { method: 'DELETE' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(resp) {
|
||||||
|
if (resp.code === 200) { showToast(resp.message, 'success'); loadKeys(); }
|
||||||
|
else { showToast(resp.message, 'error'); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load on init
|
||||||
|
loadKeys();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -17,6 +17,9 @@
|
|||||||
<a href="{{ url_for('main.expert_generate') }}" class="btn-expert">
|
<a href="{{ url_for('main.expert_generate') }}" class="btn-expert">
|
||||||
<i class="fas fa-brain"></i> 专家模式
|
<i class="fas fa-brain"></i> 专家模式
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/apikeys" class="btn-expert" style="background:#64748b;">
|
||||||
|
<i class="fas fa-key"></i> API Key
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user