diff --git a/backend/python/app/api/admin/apps_admin.py b/backend/python/app/api/admin/apps_admin.py new file mode 100644 index 0000000..a37432f --- /dev/null +++ b/backend/python/app/api/admin/apps_admin.py @@ -0,0 +1,844 @@ +"""AI应用管理 - Admin API + +提供AI应用的完整生命周期管理: +- 应用CRUD + 生命周期(草稿→发布→暂停→归档) +- 应用模板(快速创建) +- 版本管理(配置快照、回滚) +- 应用分析(对话数、Token消耗、活跃用户、响应时间) +- 分类统计 +""" + +from fastapi import APIRouter, HTTPException, Query, Body +from pydantic import BaseModel +from typing import Optional, List +from sqlalchemy import text +from datetime import datetime, timedelta +import json +import uuid + +router = APIRouter() + +# Database - use app's existing connection +from ...database import SessionLocal + +# Timezone +from datetime import timezone +CST_TZ = timezone(timedelta(hours=8)) + + +def cst_now(): + return datetime.now(CST_TZ).replace(tzinfo=None) + + +def fmt_time(dt_val): + if not dt_val: + return None + if hasattr(dt_val, 'replace'): + return dt_val.replace(tzinfo=CST_TZ).isoformat() + return str(dt_val) + + +def gen_id(): + return str(uuid.uuid4()) + +# ============================================================ +# Pydantic Models +# ============================================================ + +class AIAppCreate(BaseModel): + name: str + description: Optional[str] = None + type: str = "chatbot" # chatbot / assistant / workflow / api_service + category: str = "custom" + tags: Optional[str] = None + icon: Optional[str] = None + config: Optional[dict] = None + + +class AIAppUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + type: Optional[str] = None + category: Optional[str] = None + tags: Optional[str] = None + icon: Optional[str] = None + config: Optional[dict] = None + status: Optional[str] = None + + +class AIAppVersionCreate(BaseModel): + changelog: Optional[str] = None + + +class TemplateCreate(BaseModel): + name: str + description: Optional[str] = None + type: str = "chatbot" + category: str = "custom" + icon: Optional[str] = None + config: dict + + +# ============================================================ +# Stats Overview +# ============================================================ + +@router.get("/stats/overview") +async def stats_overview(): + """AI应用总览统计""" + db = SessionLocal() + try: + # Total apps + total = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE is_template = 0")).scalar() or 0 + published = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'published' AND is_template = 0")).scalar() or 0 + draft = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'draft' AND is_template = 0")).scalar() or 0 + paused = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'paused' AND is_template = 0")).scalar() or 0 + archived = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'archived' AND is_template = 0")).scalar() or 0 + templates = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE is_template = 1")).scalar() or 0 + + # By type + type_rows = db.execute(text( + "SELECT type, COUNT(*) as cnt FROM ai_applications WHERE is_template = 0 GROUP BY type" + )).fetchall() + by_type = {row[0]: row[1] for row in type_rows} + + # By category + cat_rows = db.execute(text( + "SELECT category, COUNT(*) as cnt FROM ai_applications WHERE is_template = 0 GROUP BY category" + )).fetchall() + by_category = {row[0]: row[1] for row in cat_rows} + + # Recent 7 days new apps + seven_days_ago = cst_now() - timedelta(days=7) + recent = db.execute(text( + "SELECT COUNT(*) FROM ai_applications WHERE created_at >= :dt AND is_template = 0" + ), {"dt": seven_days_ago}).scalar() or 0 + + # Total versions + total_versions = db.execute(text("SELECT COUNT(*) FROM ai_app_versions")).scalar() or 0 + + return { + "code": 0, + "data": { + "total": total, + "published": published, + "draft": draft, + "paused": paused, + "archived": archived, + "templates": templates, + "by_type": by_type, + "by_category": by_category, + "recent_7d": recent, + "total_versions": total_versions + } + } + finally: + db.close() + + +# ============================================================ +# App CRUD +# ============================================================ + +@router.get("/apps") +async def list_apps( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = Query(None), + app_type: Optional[str] = Query(None), + status: Optional[str] = Query(None), + category: Optional[str] = Query(None), + is_template: bool = Query(False) +): + """获取应用列表""" + db = SessionLocal() + try: + where = "WHERE is_template = :is_template" + params = {"is_template": 1 if is_template else 0} + + if search: + where += " AND (name LIKE :search OR description LIKE :search2)" + params["search"] = f"%{search}%" + params["search2"] = f"%{search}%" + if app_type: + where += " AND type = :app_type" + params["app_type"] = app_type + if status: + where += " AND status = :status" + params["status"] = status + if category: + where += " AND category = :category" + params["category"] = category + + total = db.execute(text(f"SELECT COUNT(*) FROM ai_applications {where}"), params).scalar() or 0 + + offset = (page - 1) * page_size + rows = db.execute(text( + f"SELECT id, name, description, type, category, tags, icon, status, version, " + f"is_template, is_public, config, created_at, updated_at, published_at " + f"FROM ai_applications {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset" + ), {**params, "limit": page_size, "offset": offset}).fetchall() + + apps = [] + for row in rows: + config = row[11] + if isinstance(config, str): + try: + config = json.loads(config) + except: + config = {} + apps.append({ + "id": row[0], + "name": row[1], + "description": row[2], + "type": row[3], + "category": row[4], + "tags": row[5], + "icon": row[6], + "status": row[7], + "version": row[8], + "is_template": bool(row[9]), + "is_public": bool(row[10]), + "config": config, + "created_at": fmt_time(row[12]), + "updated_at": fmt_time(row[13]), + "published_at": fmt_time(row[14]) + }) + + return { + "code": 0, + "data": { + "items": apps, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } + } + finally: + db.close() + + +@router.post("/apps") +async def create_app(app: AIAppCreate): + """创建AI应用""" + db = SessionLocal() + try: + app_id = gen_id() + now = cst_now() + config_json = json.dumps(app.config or { + "model_id": None, + "system_prompt": "", + "temperature": 0.7, + "max_tokens": 2000, + "skill_ids": [], + "knowledge_base_ids": [], + "tools": [], + "welcome_message": "" + }, ensure_ascii=False) + + db.execute(text( + "INSERT INTO ai_applications (id, name, description, type, category, tags, icon, " + "status, version, is_template, is_public, config, created_at, updated_at) " + "VALUES (:id, :name, :desc, :type, :cat, :tags, :icon, 'draft', 1, 0, 0, :config, :now, :now)" + ), { + "id": app_id, "name": app.name, "desc": app.description, + "type": app.type, "cat": app.category, "tags": app.tags, + "icon": app.icon, "config": config_json, "now": now + }) + db.commit() + + # Save initial version + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:id, :app_id, 1, :config, :changelog, :now)" + ), { + "id": version_id, "app_id": app_id, "config": config_json, + "changelog": "Initial version", "now": now + }) + db.commit() + + return {"code": 0, "data": {"id": app_id, "message": "Application created"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.get("/apps/{app_id}") +async def get_app(app_id: str): + """获取应用详情""" + db = SessionLocal() + try: + row = db.execute(text( + "SELECT id, name, description, type, category, tags, icon, status, version, " + "is_template, is_public, config, created_at, updated_at, published_at " + "FROM ai_applications WHERE id = :id" + ), {"id": app_id}).fetchone() + + if not row: + raise HTTPException(404, "Application not found") + + config = row[11] + if isinstance(config, str): + try: + config = json.loads(config) + except: + config = {} + + # Get versions + version_rows = db.execute(text( + "SELECT id, version, changelog, created_at FROM ai_app_versions " + "WHERE app_id = :id ORDER BY version DESC" + ), {"id": app_id}).fetchall() + + versions = [{ + "id": v[0], "version": v[1], "changelog": v[2], + "created_at": fmt_time(v[3]) + } for v in version_rows] + + # Get analytics summary (last 7 days) + seven_days_ago = cst_now() - timedelta(days=7) + analytics = db.execute(text( + "SELECT COALESCE(SUM(conversations), 0), COALESCE(SUM(messages), 0), " + "COALESCE(SUM(tokens_input + tokens_output), 0), COALESCE(SUM(active_users), 0), " + "COALESCE(SUM(error_count), 0) " + "FROM ai_app_analytics WHERE app_id = :id AND date >= :dt" + ), {"id": app_id, "dt": seven_days_ago}).fetchone() + + return { + "code": 0, + "data": { + "id": row[0], "name": row[1], "description": row[2], + "type": row[3], "category": row[4], "tags": row[5], + "icon": row[6], "status": row[7], "version": row[8], + "is_template": bool(row[9]), "is_public": bool(row[10]), + "config": config, + "created_at": fmt_time(row[12]), + "updated_at": fmt_time(row[13]), + "published_at": fmt_time(row[14]), + "versions": versions, + "analytics_7d": { + "conversations": analytics[0], + "messages": analytics[1], + "tokens": analytics[2], + "active_users": analytics[3], + "errors": analytics[4] + } + } + } + finally: + db.close() + + +@router.put("/apps/{app_id}") +async def update_app(app_id: str, app: AIAppUpdate): + """更新应用""" + db = SessionLocal() + try: + existing = db.execute(text("SELECT config, version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone() + if not existing: + raise HTTPException(404, "Application not found") + + old_config = existing[0] + old_version = existing[1] + now = cst_now() + + updates = [] + params = {"id": app_id, "now": now} + + if app.name is not None: + updates.append("name = :name") + params["name"] = app.name + if app.description is not None: + updates.append("description = :desc") + params["desc"] = app.description + if app.type is not None: + updates.append("type = :type") + params["type"] = app.type + if app.category is not None: + updates.append("category = :cat") + params["cat"] = app.category + if app.tags is not None: + updates.append("tags = :tags") + params["tags"] = app.tags + if app.icon is not None: + updates.append("icon = :icon") + params["icon"] = app.icon + if app.status is not None: + updates.append("status = :status") + params["status"] = app.status + if app.status == "published": + updates.append("published_at = :now") + + config_changed = False + if app.config is not None: + new_config = json.dumps(app.config, ensure_ascii=False) + if new_config != old_config: + updates.append("config = :config") + params["config"] = new_config + config_changed = True + + if not updates: + return {"code": 0, "data": {"message": "No changes"}} + + updates.append("updated_at = :now") + db.execute(text(f"UPDATE ai_applications SET {', '.join(updates)} WHERE id = :id"), params) + + # Save new version if config changed + if config_changed: + new_version = old_version + 1 + db.execute(text("UPDATE ai_applications SET version = :v WHERE id = :id"), {"v": new_version, "id": app_id}) + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, :v, :config, :cl, :now)" + ), { + "vid": version_id, "aid": app_id, "v": new_version, + "config": new_config, "cl": f"Version {new_version}", "now": now + }) + + db.commit() + return {"code": 0, "data": {"message": "Application updated"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.delete("/apps/{app_id}") +async def delete_app(app_id: str): + """删除应用""" + db = SessionLocal() + try: + result = db.execute(text("DELETE FROM ai_applications WHERE id = :id"), {"id": app_id}) + if result.rowcount == 0: + raise HTTPException(404, "Application not found") + db.execute(text("DELETE FROM ai_app_versions WHERE app_id = :id"), {"id": app_id}) + db.execute(text("DELETE FROM ai_app_analytics WHERE app_id = :id"), {"id": app_id}) + db.commit() + return {"code": 0, "data": {"message": "Application deleted"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +# ============================================================ +# Lifecycle Management +# ============================================================ + +@router.post("/apps/{app_id}/publish") +async def publish_app(app_id: str): + """发布应用""" + db = SessionLocal() + try: + now = cst_now() + result = db.execute(text( + "UPDATE ai_applications SET status = 'published', published_at = :now, updated_at = :now WHERE id = :id" + ), {"id": app_id, "now": now}) + if result.rowcount == 0: + raise HTTPException(404, "Application not found") + db.commit() + return {"code": 0, "data": {"message": "Application published"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/{app_id}/pause") +async def pause_app(app_id: str): + """暂停应用""" + db = SessionLocal() + try: + result = db.execute(text( + "UPDATE ai_applications SET status = 'paused', updated_at = :now WHERE id = :id" + ), {"id": app_id, "now": cst_now()}) + if result.rowcount == 0: + raise HTTPException(404, "Application not found") + db.commit() + return {"code": 0, "data": {"message": "Application paused"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/{app_id}/archive") +async def archive_app(app_id: str): + """归档应用""" + db = SessionLocal() + try: + result = db.execute(text( + "UPDATE ai_applications SET status = 'archived', updated_at = :now WHERE id = :id" + ), {"id": app_id, "now": cst_now()}) + if result.rowcount == 0: + raise HTTPException(404, "Application not found") + db.commit() + return {"code": 0, "data": {"message": "Application archived"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/{app_id}/duplicate") +async def duplicate_app(app_id: str): + """复制应用""" + db = SessionLocal() + try: + row = db.execute(text( + "SELECT name, description, type, category, tags, icon, config " + "FROM ai_applications WHERE id = :id" + ), {"id": app_id}).fetchone() + + if not row: + raise HTTPException(404, "Application not found") + + new_id = gen_id() + now = cst_now() + db.execute(text( + "INSERT INTO ai_applications (id, name, description, type, category, tags, icon, " + "status, version, is_template, is_public, config, created_at, updated_at) " + "VALUES (:id, :name, :desc, :type, :cat, :tags, :icon, 'draft', 1, 0, 0, :config, :now, :now)" + ), { + "id": new_id, "name": row[0] + " (Copy)", "desc": row[1], + "type": row[2], "cat": row[3], "tags": row[4], + "icon": row[5], "config": row[6], "now": now + }) + + # Copy initial version + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, 1, :config, :cl, :now)" + ), {"vid": version_id, "aid": new_id, "config": row[6], "cl": "Copied from original", "now": now}) + + db.commit() + return {"code": 0, "data": {"id": new_id, "message": "Application duplicated"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +# ============================================================ +# Version Management +# ============================================================ + +@router.get("/apps/{app_id}/versions") +async def list_versions(app_id: str): + """获取应用版本历史""" + db = SessionLocal() + try: + rows = db.execute(text( + "SELECT id, version, config, changelog, created_at " + "FROM ai_app_versions WHERE app_id = :id ORDER BY version DESC" + ), {"id": app_id}).fetchall() + + versions = [] + for row in rows: + config = row[2] + if isinstance(config, str): + try: + config = json.loads(config) + except: + config = {} + versions.append({ + "id": row[0], "version": row[1], "config": config, + "changelog": row[3], "created_at": fmt_time(row[4]) + }) + + return {"code": 0, "data": {"items": versions, "total": len(versions)}} + finally: + db.close() + + +@router.post("/apps/{app_id}/versions") +async def save_version(app_id: str, body: AIAppVersionCreate): + """保存当前配置为新版本""" + db = SessionLocal() + try: + row = db.execute(text("SELECT config, version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone() + if not row: + raise HTTPException(404, "Application not found") + + new_version = row[1] + 1 + version_id = gen_id() + now = cst_now() + + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, :v, :config, :cl, :now)" + ), { + "vid": version_id, "aid": app_id, "v": new_version, + "config": row[0], "cl": body.changelog or f"Version {new_version}", "now": now + }) + db.execute(text("UPDATE ai_applications SET version = :v, updated_at = :now WHERE id = :id"), + {"v": new_version, "now": now, "id": app_id}) + db.commit() + return {"code": 0, "data": {"version": new_version, "message": "Version saved"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/{app_id}/versions/{version}/restore") +async def restore_version(app_id: str, version: int): + """恢复到指定版本""" + db = SessionLocal() + try: + row = db.execute(text( + "SELECT config FROM ai_app_versions WHERE app_id = :id AND version = :v" + ), {"id": app_id, "v": version}).fetchone() + + if not row: + raise HTTPException(404, "Version not found") + + now = cst_now() + current = db.execute(text("SELECT version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone() + if not current: + raise HTTPException(404, "Application not found") + + new_version = current[0] + 1 + db.execute(text( + "UPDATE ai_applications SET config = :config, version = :v, updated_at = :now WHERE id = :id" + ), {"config": row[0], "v": new_version, "now": now, "id": app_id}) + + # Save restored version + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, :v, :config, :cl, :now)" + ), { + "vid": version_id, "aid": app_id, "v": new_version, + "config": row[0], "cl": f"Restored from v{version}", "now": now + }) + + db.commit() + return {"code": 0, "data": {"version": new_version, "message": f"Restored to v{version}"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +# ============================================================ +# Analytics +# ============================================================ + +@router.get("/apps/{app_id}/analytics") +async def app_analytics(app_id: str, days: int = Query(7, ge=1, le=90)): + """获取应用分析数据""" + db = SessionLocal() + try: + start_date = cst_now() - timedelta(days=days) + rows = db.execute(text( + "SELECT date, conversations, messages, tokens_input, tokens_output, " + "active_users, avg_response_ms, error_count " + "FROM ai_app_analytics WHERE app_id = :id AND date >= :dt ORDER BY date" + ), {"id": app_id, "dt": start_date}).fetchall() + + daily = [] + for row in rows: + daily.append({ + "date": row[0].isoformat() if row[0] else None, + "conversations": row[1], "messages": row[2], + "tokens_input": row[3], "tokens_output": row[4], + "active_users": row[5], "avg_response_ms": row[6], + "errors": row[7] + }) + + # Summary + total_convs = sum(d["conversations"] for d in daily) + total_msgs = sum(d["messages"] for d in daily) + total_tokens_in = sum(d["tokens_input"] for d in daily) + total_tokens_out = sum(d["tokens_output"] for d in daily) + total_users = sum(d["active_users"] for d in daily) + total_errors = sum(d["errors"] for d in daily) + avg_response = sum(d["avg_response_ms"] for d in daily) / len(daily) if daily else 0 + + return { + "code": 0, + "data": { + "daily": daily, + "summary": { + "conversations": total_convs, + "messages": total_msgs, + "tokens_input": total_tokens_in, + "tokens_output": total_tokens_out, + "active_users": total_users, + "errors": total_errors, + "avg_response_ms": round(avg_response, 1), + "error_rate": round(total_errors / total_msgs * 100, 2) if total_msgs else 0 + }, + "days": days + } + } + finally: + db.close() + + +# ============================================================ +# Templates +# ============================================================ + +@router.get("/templates") +async def list_templates( + type: Optional[str] = Query(None), + category: Optional[str] = Query(None) +): + """获取应用模板列表""" + db = SessionLocal() + try: + where = "WHERE is_template = 1" + params = {} + if type: + where += " AND type = :type" + params["type"] = type + if category: + where += " AND category = :category" + params["category"] = category + + rows = db.execute(text( + f"SELECT id, name, description, type, category, icon, config, created_at " + f"FROM ai_applications {where} ORDER BY created_at DESC" + ), params).fetchall() + + templates = [] + for row in rows: + config = row[6] + if isinstance(config, str): + try: + config = json.loads(config) + except: + config = {} + templates.append({ + "id": row[0], "name": row[1], "description": row[2], + "type": row[3], "category": row[4], "icon": row[5], + "config": config, "created_at": fmt_time(row[7]) + }) + + return {"code": 0, "data": {"items": templates, "total": len(templates)}} + finally: + db.close() + + +@router.post("/templates") +async def create_template(tpl: TemplateCreate): + """创建应用模板""" + db = SessionLocal() + try: + tpl_id = gen_id() + now = cst_now() + config_json = json.dumps(tpl.config, ensure_ascii=False) + + db.execute(text( + "INSERT INTO ai_applications (id, name, description, type, category, icon, " + "status, version, is_template, is_public, config, created_at, updated_at) " + "VALUES (:id, :name, :desc, :type, :cat, :icon, 'published', 1, 1, 1, :config, :now, :now)" + ), { + "id": tpl_id, "name": tpl.name, "desc": tpl.description, + "type": tpl.type, "cat": tpl.category, "icon": tpl.icon, + "config": config_json, "now": now + }) + db.commit() + return {"code": 0, "data": {"id": tpl_id, "message": "Template created"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +@router.post("/apps/from-template/{template_id}") +async def create_from_template(template_id: str, body: dict = Body(default={})): + """从模板创建应用""" + db = SessionLocal() + try: + row = db.execute(text( + "SELECT name, description, type, category, config FROM ai_applications WHERE id = :id AND is_template = 1" + ), {"id": template_id}).fetchone() + + if not row: + raise HTTPException(404, "Template not found") + + new_id = gen_id() + now = cst_now() + custom_name = body.get("name", row[0] + " (Instance)") + + db.execute(text( + "INSERT INTO ai_applications (id, name, description, type, category, tags, icon, " + "status, version, is_template, is_public, config, created_at, updated_at) " + "VALUES (:id, :name, :desc, :type, :cat, NULL, NULL, 'draft', 1, 0, 0, :config, :now, :now)" + ), { + "id": new_id, "name": custom_name, "desc": row[1], + "type": row[2], "cat": row[3], "config": row[4], "now": now + }) + + version_id = gen_id() + db.execute(text( + "INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) " + "VALUES (:vid, :aid, 1, :config, :cl, :now)" + ), {"vid": version_id, "aid": new_id, "config": row[4], "cl": "Created from template", "now": now}) + + db.commit() + return {"code": 0, "data": {"id": new_id, "message": "Application created from template"}} + except Exception as e: + db.rollback() + raise HTTPException(500, str(e)) + finally: + db.close() + + +# ============================================================ +# Categories +# ============================================================ + +@router.get("/categories") +async def list_categories(): + """获取应用分类统计""" + db = SessionLocal() + try: + rows = db.execute(text( + "SELECT category, COUNT(*) as cnt, " + "SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published " + "FROM ai_applications WHERE is_template = 0 " + "GROUP BY category ORDER BY cnt DESC" + )).fetchall() + + categories = [] + category_names = { + "customer_service": "客服助手", + "data_analysis": "数据分析", + "coding": "编程辅助", + "writing": "内容创作", + "education": "教育培训", + "marketing": "营销推广", + "hr": "人力资源", + "finance": "财务法务", + "custom": "自定义" + } + for row in rows: + categories.append({ + "key": row[0], + "name": category_names.get(row[0], row[0]), + "count": row[1], + "published": row[2] + }) + + return {"code": 0, "data": {"items": categories}} + finally: + db.close() diff --git a/backend/python/main.py b/backend/python/main.py index ada2a88..1c9f351 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin, developer_admin +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD from app.database import SessionLocal @@ -423,6 +423,7 @@ app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=[ app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) +app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5add7a..f643b65 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -76,7 +76,8 @@ import AdminKnowledge from '@/pages/admin/Knowledge'; import AdminStorage from '@/pages/admin/Storage'; import AdminMcp from '@/pages/admin/Mcp'; import AdminAutomation from '@/pages/admin/Automation'; -import AdminDeveloper from '@/pages/admin/Developer';; +import AdminDeveloper from '@/pages/admin/Developer'; +import AdminApps from '@/pages/admin/Apps';; @@ -238,6 +239,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 9d5d0fb..96cae91 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -575,6 +575,21 @@ export const adminAPI = { }, clearAdminSlowLogs: () => del('/admin/developer/slow-logs'), getAdminSlowLogsStats: () => get('/admin/developer/slow-logs/stats'), + // AI App Management + getAIAppStats: () => get('/admin/apps/stats/overview'), + getAIApps: (params?: string) => get('/admin/apps/apps' + (params ? '?' + params : '')), + getAIApp: (id: string) => get('/admin/apps/apps/' + id), + createAIApp: (data: any) => post('/admin/apps/apps', data), + updateAIApp: (id: string, data: any) => put('/admin/apps/apps/' + id, data), + deleteAIApp: (id: string) => del('/admin/apps/apps/' + id), + aiAppLifecycle: (id: string, action: string) => post('/admin/apps/apps/' + id + '/' + action, {}), + getAIAppVersions: (id: string) => get('/admin/apps/apps/' + id + '/versions'), + getAIAppAnalytics: (id: string) => get('/admin/apps/apps/' + id + '/analytics'), + getAIAppTemplates: () => get('/admin/apps/templates'), + createAIAppTemplate: (data: any) => post('/admin/apps/templates', data), + createAIAppFromTemplate: (templateId: string, data: any) => post('/admin/apps/apps/from-template/' + templateId, data), + getAIAppCategories: () => get('/admin/apps/categories') + }; diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index a24bd75..544ddd8 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -38,9 +38,9 @@ const adminNavItems = [ { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, { path: '/admin/skills', icon: 'skills', label: '技能管理' }, { path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' }, - { path: '/admin/developer', icon: 'developer', label: '开发者API' },]; - - + { path: '/admin/developer', icon: 'developer', label: '开发者API' }, + { path: '/admin/apps', icon: 'apps', label: 'AI应用管理' } +]; const adminIconPaths: Record = { diff --git a/frontend/src/pages/admin/Apps.css b/frontend/src/pages/admin/Apps.css new file mode 100644 index 0000000..01adff0 --- /dev/null +++ b/frontend/src/pages/admin/Apps.css @@ -0,0 +1,519 @@ +.admin-apps-page { + padding: 24px; +} + +.admin-apps-page .admin-page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.admin-apps-page .admin-page-header h1 { + font-size: 24px; + font-weight: 600; + margin: 0; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 20px; + text-align: center; + transition: box-shadow 0.2s; +} + +.stat-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.stat-value { + font-size: 28px; + font-weight: 700; + color: var(--color-text-primary); + line-height: 1.2; +} + +.stat-label { + font-size: 13px; + color: var(--color-text-tertiary); + margin-top: 4px; +} + +/* Tabs */ +.tabs-bar { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 1px solid var(--color-border); + padding-bottom: 0; +} + +.tab-btn { + padding: 8px 16px; + border: none; + background: transparent; + cursor: pointer; + font-size: 14px; + color: var(--color-text-secondary); + border-bottom: 2px solid transparent; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 6px; +} + +.tab-btn:hover { + color: var(--color-text-primary); +} + +.tab-btn.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); + font-weight: 500; +} + +.tab-badge { + background: var(--color-accent); + color: white; + font-size: 11px; + padding: 1px 7px; + border-radius: 10px; + font-weight: 500; +} + +.tab-btn:not(.active) .tab-badge { + background: var(--color-text-disabled); +} + +/* Filter Bar */ +.filter-bar { + display: flex; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.filter-input, .filter-select { + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 14px; + background: var(--color-surface); + color: var(--color-text-primary); + outline: none; + transition: border-color 0.2s; +} + +.filter-input { + flex: 1; + min-width: 200px; +} + +.filter-input:focus, .filter-select:focus { + border-color: var(--color-accent); +} + +/* Table */ +.admin-table { + width: 100%; + border-collapse: collapse; + background: var(--color-surface); + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--color-border); +} + +.admin-table th { + padding: 12px 16px; + text-align: left; + font-size: 13px; + font-weight: 500; + color: var(--color-text-tertiary); + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); +} + +.admin-table td { + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); + font-size: 14px; + color: var(--color-text-primary); +} + +.admin-table tr:hover { + background: var(--color-bg-secondary); +} + +.admin-table tr:last-child td { + border-bottom: none; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + background: var(--color-bg-secondary); + color: var(--color-text-secondary); +} + +.status-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +/* Buttons */ +.btn-primary, .btn-secondary, .btn-sm, .btn-danger, .btn-success, .btn-block { + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; +} + +.btn-primary { + padding: 8px 16px; + background: var(--color-accent); + color: white; +} + +.btn-primary:hover { opacity: 0.9; } + +.btn-secondary { + padding: 8px 16px; + background: var(--color-bg-secondary); + color: var(--color-text-primary); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover { background: var(--color-border); } + +.btn-sm { + padding: 4px 10px; + font-size: 12px; + background: var(--color-bg-secondary); + color: var(--color-text-primary); + border: 1px solid var(--color-border); +} + +.btn-sm:hover { background: var(--color-border); } + +.btn-success { + padding: 4px 10px; + font-size: 12px; + background: #10b981; + color: white; +} + +.btn-success:hover { opacity: 0.9; } + +.btn-danger { + padding: 4px 10px; + font-size: 12px; + background: #ef4444; + color: white; +} + +.btn-danger:hover { opacity: 0.9; } + +.btn-block { + width: 100%; + padding: 8px 16px; + background: var(--color-accent); + color: white; + margin-top: 8px; +} + +.btn-block:hover { opacity: 0.9; } + +/* Pagination */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 20px; + font-size: 14px; + color: var(--color-text-secondary); +} + +.pagination button { + padding: 6px 14px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-surface); + cursor: pointer; + font-size: 14px; +} + +.pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Templates Grid */ +.templates-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.template-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 8px; + transition: box-shadow 0.2s; +} + +.template-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.template-icon { + font-size: 32px; + margin-bottom: 4px; +} + +.template-name { + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); +} + +.template-desc { + font-size: 13px; + color: var(--color-text-tertiary); + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.template-meta { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +/* Analytics */ +.analytics-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 20px; + text-align: center; + min-width: 120px; +} + +.analytics-value { + font-size: 24px; + font-weight: 700; + color: var(--color-text-primary); +} + +.analytics-label { + font-size: 13px; + color: var(--color-text-tertiary); + margin-top: 4px; +} + +/* Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: var(--color-surface); + border-radius: 12px; + width: 90%; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--color-border); +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.modal-close { + border: none; + background: none; + font-size: 24px; + cursor: pointer; + color: var(--color-text-tertiary); + padding: 0; + line-height: 1; +} + +.modal-close:hover { color: var(--color-text-primary); } + +.modal-body { + padding: 24px; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid var(--color-border); +} + +/* Form */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); + margin-bottom: 6px; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 14px; + background: var(--color-surface); + color: var(--color-text-primary); + outline: none; + transition: border-color 0.2s; + font-family: inherit; + box-sizing: border-box; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + border-color: var(--color-accent); +} + +.form-row { + display: flex; + gap: 16px; +} + +.form-row .form-group { + flex: 1; +} + +.form-section-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + margin: 20px 0 12px; + padding-top: 16px; + border-top: 1px solid var(--color-border); +} + +/* Config Section */ +.config-section { + background: var(--color-bg-secondary); + border-radius: 8px; + padding: 16px; +} + +.config-item { + display: flex; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--color-border); +} + +.config-item:last-child { + border-bottom: none; +} + +.config-label { + font-size: 13px; + color: var(--color-text-tertiary); + min-width: 100px; + flex-shrink: 0; +} + +.config-value { + font-size: 13px; + color: var(--color-text-primary); + word-break: break-word; +} + +/* Version List */ +.version-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.version-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--color-bg-secondary); + border-radius: 8px; + font-size: 13px; +} + +.version-num { + font-weight: 600; + color: var(--color-accent); + min-width: 40px; +} + +.version-log { + flex: 1; + color: var(--color-text-primary); +} + +.version-date { + color: var(--color-text-tertiary); + font-size: 12px; +} diff --git a/frontend/src/pages/admin/Apps.tsx b/frontend/src/pages/admin/Apps.tsx new file mode 100644 index 0000000..8b0ec40 --- /dev/null +++ b/frontend/src/pages/admin/Apps.tsx @@ -0,0 +1,799 @@ +import { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '@/api'; +import './Apps.css'; + +interface AIApp { + id: string; + name: string; + description: string | null; + type: string; + category: string; + tags: string | null; + icon: string | null; + status: string; + version: number; + is_template: boolean; + is_public: boolean; + config: any; + created_at: string | null; + updated_at: string | null; + published_at: string | null; + analytics_7d?: any; + versions?: any[]; +} + +interface AppStats { + total: number; + published: number; + draft: number; + paused: number; + archived: number; + templates: number; + by_type: Record; + by_category: Record; + recent_7d: number; + total_versions: number; +} + +interface Template { + id: string; + name: string; + description: string | null; + type: string; + category: string; + icon: string | null; + config: any; + created_at: string | null; +} + +const CATEGORY_NAMES: Record = { + customer_service: '客服助手', + data_analysis: '数据分析', + coding: '编程辅助', + writing: '内容创作', + education: '教育培训', + marketing: '营销推广', + hr: '人力资源', + finance: '财务法务', + custom: '自定义', +}; + +const TYPE_NAMES: Record = { + chatbot: '聊天机器人', + assistant: 'AI助手', + workflow: '工作流', + api_service: 'API服务', +}; + +const STATUS_COLORS: Record = { + draft: '#f59e0b', + published: '#10b981', + paused: '#6b7280', + archived: '#9ca3af', +}; + +const STATUS_NAMES: Record = { + draft: '草稿', + published: '已发布', + paused: '已暂停', + archived: '已归档', +}; + +export default function AdminApps() { + const [activeTab, setActiveTab] = useState<'apps' | 'templates' | 'analytics'>('apps'); + const [stats, setStats] = useState(null); + const [apps, setApps] = useState([]); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [filterType, setFilterType] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); + const [filterCategory, setFilterCategory] = useState(''); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editApp, setEditApp] = useState(null); + const [detailApp, setDetailApp] = useState(null); + const [detailAnalytics, setDetailAnalytics] = useState(null); + const [showTemplateModal, setShowTemplateModal] = useState(false); + + const fetchStats = useCallback(async () => { + try { + const res = await adminAPI.getAIAppStats(); + if (res.code === 0) setStats(res.data); + } catch (e) { console.error(e); } + }, []); + + const fetchApps = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + params.set('page', String(page)); + params.set('page_size', '20'); + if (search) params.set('search', search); + if (filterType) params.set('app_type', filterType); + if (filterStatus) params.set('status', filterStatus); + if (filterCategory) params.set('category', filterCategory); + const res = await adminAPI.getAIApps(params.toString()); + if (res.code === 0) { + setApps(res.data.items || []); + setTotal(res.data.total || 0); + setTotalPages(res.data.total_pages || 1); + } + } catch (e) { console.error(e); } + finally { setLoading(false); } + }, [page, search, filterType, filterStatus, filterCategory]); + + const fetchTemplates = useCallback(async () => { + try { + const res = await adminAPI.getAIAppTemplates(); + if (res.code === 0) setTemplates(res.data.items || []); + } catch (e) { console.error(e); } + }, []); + + const fetchDetail = useCallback(async (id: string) => { + try { + const res = await adminAPI.getAIApp(id); + if (res.code === 0) { + setDetailApp(res.data); + // Fetch analytics + try { + const aRes = await adminAPI.getAIAppAnalytics(id); + if (aRes.code === 0) setDetailAnalytics(aRes.data); + } catch (e) { /* analytics may be empty */ } + } + } catch (e) { console.error(e); } + }, []); + + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + useEffect(() => { + if (activeTab === 'apps') fetchApps(); + if (activeTab === 'templates') fetchTemplates(); + }, [activeTab, fetchApps, fetchTemplates]); + + const handleCreate = async (data: any) => { + try { + const res = await adminAPI.createAIApp(data); + if (res.code === 0) { + setShowCreateModal(false); + fetchApps(); + fetchStats(); + } + } catch (e) { console.error(e); } + }; + + const handleUpdate = async (data: any) => { + if (!editApp) return; + try { + const res = await adminAPI.updateAIApp(editApp.id, data); + if (res.code === 0) { + setEditApp(null); + fetchApps(); + fetchStats(); + } + } catch (e) { console.error(e); } + }; + + const handleDelete = async (id: string) => { + if (!confirm('确定要删除此应用吗?此操作不可逆。')) return; + try { + const res = await adminAPI.deleteAIApp(id); + if (res.code === 0) { + fetchApps(); + fetchStats(); + } + } catch (e) { console.error(e); } + }; + + const handleLifecycle = async (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => { + try { + const res = await adminAPI.aiAppLifecycle(id, action); + if (res.code === 0) { + fetchApps(); + fetchStats(); + if (detailApp?.id === id) fetchDetail(id); + } + } catch (e) { console.error(e); } + }; + + const handleCreateTemplate = async (data: any) => { + try { + const res = await adminAPI.createAIAppTemplate(data); + if (res.code === 0) { + setShowTemplateModal(false); + fetchTemplates(); + fetchStats(); + } + } catch (e) { console.error(e); } + }; + + const handleFromTemplate = async (templateId: string, name: string) => { + try { + const res = await adminAPI.createAIAppFromTemplate(templateId, { name }); + if (res.code === 0) { + setActiveTab('apps'); + fetchApps(); + fetchStats(); + } + } catch (e) { console.error(e); } + }; + + const formatDate = (dt: string | null) => { + if (!dt) return '-'; + try { + const d = new Date(dt); + return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + } catch { return dt; } + }; + + return ( +
+
+

AI应用管理

+
+ {activeTab === 'apps' && ( + + )} + {activeTab === 'templates' && ( + + )} +
+
+ + {/* Stats Cards */} + {stats && ( +
+
+
{stats.total}
+
应用总数
+
+
+
{stats.published}
+
已发布
+
+
+
{stats.draft}
+
草稿
+
+
+
{stats.templates}
+
应用模板
+
+
+
{stats.recent_7d}
+
近7天新增
+
+
+ )} + + {/* Tabs */} +
+ {([ + { key: 'apps' as const, label: '应用列表', count: stats?.total }, + { key: 'templates' as const, label: '应用模板', count: stats?.templates }, + { key: 'analytics' as const, label: '分析概览', count: undefined as number | undefined }, + ]).map(tab => ( + + ))} +
+ + {/* Apps Tab */} + {activeTab === 'apps' && ( +
+ {/* Filters */} +
+ { setSearch(e.target.value); setPage(1); }} + className="filter-input" + /> + + + +
+ + {/* Table */} + + + + + + + + + + + + + + {loading ? ( + + ) : apps.length === 0 ? ( + + ) : apps.map(app => ( + fetchDetail(app.id)}> + + + + + + + + + ))} + +
应用名称类型分类状态版本创建时间操作
加载中...
暂无应用
+
{app.name}
+ {app.description &&
{app.description}
} +
{TYPE_NAMES[app.type] || app.type}{CATEGORY_NAMES[app.category] || app.category} + + {STATUS_NAMES[app.status] || app.status} + + v{app.version}{formatDate(app.created_at)} e.stopPropagation()}> +
+ + {app.status === 'draft' && } + {app.status === 'published' && } + {app.status === 'paused' && } + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + 第 {page} / {totalPages} 页 (共 {total} 条) + +
+ )} +
+ )} + + {/* Templates Tab */} + {activeTab === 'templates' && ( +
+ {templates.length === 0 ? ( +
+ 暂无模板,点击右上角"创建模板"添加 +
+ ) : templates.map(tpl => ( +
+
{tpl.icon || '📦'}
+
{tpl.name}
+
{tpl.description || '暂无描述'}
+
+ {TYPE_NAMES[tpl.type] || tpl.type} + {CATEGORY_NAMES[tpl.category] || tpl.category} +
+ +
+ ))} +
+ )} + + {/* Analytics Tab */} + {activeTab === 'analytics' && stats && ( +
+

应用类型分布

+
+ {Object.entries(stats.by_type).map(([type, count]) => ( +
+
{count}
+
{TYPE_NAMES[type] || type}
+
+ ))} + {Object.keys(stats.by_type).length === 0 && ( +
暂无数据
+ )} +
+ +

分类分布

+
+ {Object.entries(stats.by_category).map(([cat, count]) => ( +
+
{count}
+
{CATEGORY_NAMES[cat] || cat}
+
+ ))} + {Object.keys(stats.by_category).length === 0 && ( +
暂无数据
+ )} +
+ +

版本统计

+
+
{stats.total_versions}
+
总版本数
+
+
+ )} + + {/* Create/Edit Modal */} + {(showCreateModal || editApp) && ( + { setShowCreateModal(false); setEditApp(null); }} + onSubmit={editApp ? handleUpdate : handleCreate} + /> + )} + + {/* Template Modal */} + {showTemplateModal && ( + setShowTemplateModal(false)} + onSubmit={handleCreateTemplate} + /> + )} + + {/* Detail Drawer */} + {detailApp && ( + { setDetailApp(null); setDetailAnalytics(null); }} + onAction={handleLifecycle} + /> + )} +
+ ); +} + +// ============================================================ +// App Form Modal +// ============================================================ + +function AppFormModal({ app, onClose, onSubmit }: { + app: AIApp | null; + onClose: () => void; + onSubmit: (data: any) => void; +}) { + const [name, setName] = useState(app?.name || ''); + const [description, setDescription] = useState(app?.description || ''); + const [type, setType] = useState(app?.type || 'chatbot'); + const [category, setCategory] = useState(app?.category || 'custom'); + const [tags, setTags] = useState(app?.tags || ''); + const [systemPrompt, setSystemPrompt] = useState(app?.config?.system_prompt || ''); + const [temperature, setTemperature] = useState(app?.config?.temperature ?? 0.7); + const [maxTokens, setMaxTokens] = useState(app?.config?.max_tokens ?? 2000); + const [welcomeMessage, setWelcomeMessage] = useState(app?.config?.welcome_message || ''); + + const handleSubmit = () => { + if (!name.trim()) { alert('请输入应用名称'); return; } + onSubmit({ + name, description, type, category, tags, + config: { + ...(app?.config || {}), + system_prompt: systemPrompt, + temperature: Number(temperature), + max_tokens: Number(maxTokens), + welcome_message: welcomeMessage, + } + }); + }; + + return ( +
+
e.stopPropagation()}> +
+

{app ? '编辑应用' : '创建应用'}

+ +
+
+
+ + setName(e.target.value)} placeholder="输入应用名称" /> +
+
+
+ + +
+
+ + +
+
+
+ +