feat: Phase 3.5 AI App Management - lifecycle, templates, versioning, analytics

This commit is contained in:
qyyfw committed 2026-07-03 19:41:17 +08:00
1 parent e7d1c39c18
commit 6583bd2a62
7 files changed
+2185 -5

No files matched your search

+844
View File
@@ -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()
+2 -1
View File
@@ -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 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.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.database import SessionLocal 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(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(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(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"])
+3 -1
View File
@@ -76,7 +76,8 @@ import AdminKnowledge from '@/pages/admin/Knowledge';
import AdminStorage from '@/pages/admin/Storage'; import AdminStorage from '@/pages/admin/Storage';
import AdminMcp from '@/pages/admin/Mcp'; import AdminMcp from '@/pages/admin/Mcp';
import AdminAutomation from '@/pages/admin/Automation'; 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 = () => {
<Route path="storage" element={<AdminStorage />} /> <Route path="storage" element={<AdminStorage />} />
<Route path="automation" element={<AdminAutomation />} /> <Route path="automation" element={<AdminAutomation />} />
<Route path="developer" element={<AdminDeveloper />} /> <Route path="developer" element={<AdminDeveloper />} />
<Route path="apps" element={<AdminApps />} />
</Route> </Route>
+15
View File
@@ -575,6 +575,21 @@ export const adminAPI = {
}, },
clearAdminSlowLogs: () => del<any>('/admin/developer/slow-logs'), clearAdminSlowLogs: () => del<any>('/admin/developer/slow-logs'),
getAdminSlowLogsStats: () => get<any>('/admin/developer/slow-logs/stats'), getAdminSlowLogsStats: () => get<any>('/admin/developer/slow-logs/stats'),
// AI App Management
getAIAppStats: () => get<any>('/admin/apps/stats/overview'),
getAIApps: (params?: string) => get<any>('/admin/apps/apps' + (params ? '?' + params : '')),
getAIApp: (id: string) => get<any>('/admin/apps/apps/' + id),
createAIApp: (data: any) => post<any>('/admin/apps/apps', data),
updateAIApp: (id: string, data: any) => put<any>('/admin/apps/apps/' + id, data),
deleteAIApp: (id: string) => del<any>('/admin/apps/apps/' + id),
aiAppLifecycle: (id: string, action: string) => post<any>('/admin/apps/apps/' + id + '/' + action, {}),
getAIAppVersions: (id: string) => get<any>('/admin/apps/apps/' + id + '/versions'),
getAIAppAnalytics: (id: string) => get<any>('/admin/apps/apps/' + id + '/analytics'),
getAIAppTemplates: () => get<any>('/admin/apps/templates'),
createAIAppTemplate: (data: any) => post<any>('/admin/apps/templates', data),
createAIAppFromTemplate: (templateId: string, data: any) => post<any>('/admin/apps/apps/from-template/' + templateId, data),
getAIAppCategories: () => get<any>('/admin/apps/categories')
}; };
@@ -38,9 +38,9 @@ const adminNavItems = [
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' }, { path: '/admin/automation', icon: 'automation', label: '自动化任务' },
{ path: '/admin/skills', icon: 'skills', label: '技能管理' }, { path: '/admin/skills', icon: 'skills', label: '技能管理' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' }, { 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<string, JSX.Element> = { const adminIconPaths: Record<string, JSX.Element> = {
+519
View File
@@ -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;
}
+799
View File
@@ -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<string, number>;
by_category: Record<string, number>;
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<string, string> = {
customer_service: '客服助手',
data_analysis: '数据分析',
coding: '编程辅助',
writing: '内容创作',
education: '教育培训',
marketing: '营销推广',
hr: '人力资源',
finance: '财务法务',
custom: '自定义',
};
const TYPE_NAMES: Record<string, string> = {
chatbot: '聊天机器人',
assistant: 'AI助手',
workflow: '工作流',
api_service: 'API服务',
};
const STATUS_COLORS: Record<string, string> = {
draft: '#f59e0b',
published: '#10b981',
paused: '#6b7280',
archived: '#9ca3af',
};
const STATUS_NAMES: Record<string, string> = {
draft: '草稿',
published: '已发布',
paused: '已暂停',
archived: '已归档',
};
export default function AdminApps() {
const [activeTab, setActiveTab] = useState<'apps' | 'templates' | 'analytics'>('apps');
const [stats, setStats] = useState<AppStats | null>(null);
const [apps, setApps] = useState<AIApp[]>([]);
const [templates, setTemplates] = useState<Template[]>([]);
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<AIApp | null>(null);
const [detailApp, setDetailApp] = useState<AIApp | null>(null);
const [detailAnalytics, setDetailAnalytics] = useState<any>(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 (
<div className="admin-apps-page">
<div className="admin-page-header">
<h1>AI应用管理</h1>
<div style={{ display: 'flex', gap: '8px' }}>
{activeTab === 'apps' && (
<button className="btn-primary" onClick={() => setShowCreateModal(true)}>+ 创建应用</button>
)}
{activeTab === 'templates' && (
<button className="btn-primary" onClick={() => setShowTemplateModal(true)}>+ 创建模板</button>
)}
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">应用总数</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10b981' }}>{stats.published}</div>
<div className="stat-label">已发布</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#f59e0b' }}>{stats.draft}</div>
<div className="stat-label">草稿</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.templates}</div>
<div className="stat-label">应用模板</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#3b82f6' }}>{stats.recent_7d}</div>
<div className="stat-label">近7天新增</div>
</div>
</div>
)}
{/* Tabs */}
<div className="tabs-bar">
{([
{ 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 => (
<button
key={tab.key}
className={`tab-btn ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className="tab-badge">{tab.count}</span>
)} </button>
))}
</div>
{/* Apps Tab */}
{activeTab === 'apps' && (
<div>
{/* Filters */}
<div className="filter-bar">
<input
type="text"
placeholder="搜索应用名称..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
className="filter-input"
/>
<select value={filterType} onChange={e => { setFilterType(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部类型</option>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部状态</option>
{Object.entries(STATUS_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部分类</option>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
{/* Table */}
<table className="admin-table">
<thead>
<tr>
<th>应用名称</th>
<th>类型</th>
<th>分类</th>
<th>状态</th>
<th>版本</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px' }}>加载中...</td></tr>
) : apps.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)' }}>暂无应用</td></tr>
) : apps.map(app => (
<tr key={app.id} style={{ cursor: 'pointer' }} onClick={() => fetchDetail(app.id)}>
<td>
<div style={{ fontWeight: 500 }}>{app.name}</div>
{app.description && <div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{app.description}</div>}
</td>
<td><span className="badge">{TYPE_NAMES[app.type] || app.type}</span></td>
<td>{CATEGORY_NAMES[app.category] || app.category}</td>
<td>
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
{STATUS_NAMES[app.status] || app.status}
</span>
</td>
<td>v{app.version}</td>
<td style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>{formatDate(app.created_at)}</td>
<td onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<button className="btn-sm" onClick={() => setEditApp(app)}>编辑</button>
{app.status === 'draft' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>发布</button>}
{app.status === 'published' && <button className="btn-sm" onClick={() => handleLifecycle(app.id, 'pause')}>暂停</button>}
{app.status === 'paused' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>恢复</button>}
<button className="btn-sm" onClick={() => handleLifecycle(app.id, 'duplicate')}>复制</button>
<button className="btn-sm btn-danger" onClick={() => handleDelete(app.id)}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<span>第 {page} / {totalPages} 页 (共 {total} 条)</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Templates Tab */}
{activeTab === 'templates' && (
<div className="templates-grid">
{templates.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
暂无模板,点击右上角"创建模板"添加
</div>
) : templates.map(tpl => (
<div key={tpl.id} className="template-card">
<div className="template-icon">{tpl.icon || '📦'}</div>
<div className="template-name">{tpl.name}</div>
<div className="template-desc">{tpl.description || '暂无描述'}</div>
<div className="template-meta">
<span className="badge">{TYPE_NAMES[tpl.type] || tpl.type}</span>
<span className="badge">{CATEGORY_NAMES[tpl.category] || tpl.category}</span>
</div>
<button className="btn-primary btn-block" onClick={() => {
const name = prompt('请输入应用名称', tpl.name + ' (Instance)');
if (name) handleFromTemplate(tpl.id, name);
}}>从模板创建</button>
</div>
))}
</div>
)}
{/* Analytics Tab */}
{activeTab === 'analytics' && stats && (
<div>
<h3 style={{ marginBottom: '16px' }}>应用类型分布</h3>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '32px' }}>
{Object.entries(stats.by_type).map(([type, count]) => (
<div key={type} className="analytics-card">
<div className="analytics-value">{count}</div>
<div className="analytics-label">{TYPE_NAMES[type] || type}</div>
</div>
))}
{Object.keys(stats.by_type).length === 0 && (
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
)}
</div>
<h3 style={{ marginBottom: '16px' }}>分类分布</h3>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{Object.entries(stats.by_category).map(([cat, count]) => (
<div key={cat} className="analytics-card">
<div className="analytics-value">{count}</div>
<div className="analytics-label">{CATEGORY_NAMES[cat] || cat}</div>
</div>
))}
{Object.keys(stats.by_category).length === 0 && (
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
)}
</div>
<h3 style={{ marginTop: '32px', marginBottom: '16px' }}>版本统计</h3>
<div className="analytics-card" style={{ display: 'inline-block' }}>
<div className="analytics-value">{stats.total_versions}</div>
<div className="analytics-label">总版本数</div>
</div>
</div>
)}
{/* Create/Edit Modal */}
{(showCreateModal || editApp) && (
<AppFormModal
app={editApp}
onClose={() => { setShowCreateModal(false); setEditApp(null); }}
onSubmit={editApp ? handleUpdate : handleCreate}
/>
)}
{/* Template Modal */}
{showTemplateModal && (
<TemplateFormModal
onClose={() => setShowTemplateModal(false)}
onSubmit={handleCreateTemplate}
/>
)}
{/* Detail Drawer */}
{detailApp && (
<AppDetailDrawer
app={detailApp}
analytics={detailAnalytics}
onClose={() => { setDetailApp(null); setDetailAnalytics(null); }}
onAction={handleLifecycle}
/>
)}
</div>
);
}
// ============================================================
// 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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{app ? '编辑应用' : '创建应用'}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>应用名称 *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="输入应用名称" />
</div>
<div className="form-row">
<div className="form-group">
<label>类型</label>
<select value={type} onChange={e => setType(e.target.value)}>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="form-group">
<label>分类</label>
<select value={category} onChange={e => setCategory(e.target.value)}>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label>描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="应用描述" rows={2} />
</div>
<div className="form-group">
<label>标签(逗号分隔)</label>
<input type="text" value={tags} onChange={e => setTags(e.target.value)} placeholder="如:客服,电商,自动回复" />
</div>
<div className="form-section-title">AI配置</div>
<div className="form-group">
<label>系统提示词 (System Prompt)</label>
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="定义AI的角色和行为..." rows={4} />
</div>
<div className="form-row">
<div className="form-group">
<label>温度 (Temperature): {temperature}</label>
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
</div>
<div className="form-group">
<label>最大Token数</label>
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} min="100" max="32000" />
</div>
</div>
<div className="form-group">
<label>欢迎消息</label>
<input type="text" value={welcomeMessage} onChange={e => setWelcomeMessage(e.target.value)} placeholder="用户打开应用时显示的欢迎语" />
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>取消</button>
<button className="btn-primary" onClick={handleSubmit}>{app ? '保存' : '创建'}</button>
</div>
</div>
</div>
);
}
// ============================================================
// Template Form Modal
// ============================================================
function TemplateFormModal({ onClose, onSubmit }: {
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState('chatbot');
const [category, setCategory] = useState('custom');
const [systemPrompt, setSystemPrompt] = useState('');
const [temperature, setTemperature] = useState(0.7);
const [maxTokens, setMaxTokens] = useState(2000);
const handleSubmit = () => {
if (!name.trim()) { alert('请输入模板名称'); return; }
onSubmit({
name, description, type, category,
config: {
system_prompt: systemPrompt,
temperature: Number(temperature),
max_tokens: Number(maxTokens),
skill_ids: [],
knowledge_base_ids: [],
tools: [],
welcome_message: ''
}
});
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>创建应用模板</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>模板名称 *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="如:电商客服机器人" />
</div>
<div className="form-row">
<div className="form-group">
<label>类型</label>
<select value={type} onChange={e => setType(e.target.value)}>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="form-group">
<label>分类</label>
<select value={category} onChange={e => setCategory(e.target.value)}>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label>描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="模板描述" rows={2} />
</div>
<div className="form-group">
<label>系统提示词</label>
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="预置的系统提示词..." rows={4} />
</div>
<div className="form-row">
<div className="form-group">
<label>温度: {temperature}</label>
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
</div>
<div className="form-group">
<label>最大Token数</label>
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>取消</button>
<button className="btn-primary" onClick={handleSubmit}>创建模板</button>
</div>
</div>
</div>
);
}
// ============================================================
// App Detail Drawer
// ============================================================
function AppDetailDrawer({ app, analytics, onClose, onAction }: {
app: AIApp;
analytics: any;
onClose: () => void;
onAction: (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => void;
}) {
const a7 = app.analytics_7d as any;
const summary = analytics?.summary;
return (
<div className="modal-overlay" onClick={onClose} style={{ justifyContent: 'flex-end' }}>
<div className="modal-content" style={{ maxWidth: '560px', height: '100vh', borderRadius: 0 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{app.name}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body" style={{ overflow: 'auto' }}>
{/* Status & Meta */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
{STATUS_NAMES[app.status] || app.status}
</span>
<span className="badge">{TYPE_NAMES[app.type] || app.type}</span>
<span className="badge">{CATEGORY_NAMES[app.category] || app.category}</span>
<span className="badge">v{app.version}</span>
</div>
{app.description && <p style={{ color: 'var(--color-text-secondary)', marginBottom: '16px' }}>{app.description}</p>}
{/* Quick Stats */}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)', marginBottom: '24px' }}>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.conversations || 0}</div>
<div className="stat-label">7天对话</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.tokens || 0}</div>
<div className="stat-label">7天Token</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.errors || 0}</div>
<div className="stat-label">7天错误</div>
</div>
</div>
{/* Config */}
<h3 style={{ marginBottom: '12px' }}>应用配置</h3>
<div className="config-section">
{app.config?.system_prompt && (
<div className="config-item">
<span className="config-label">系统提示词</span>
<div className="config-value">{app.config.system_prompt}</div>
</div>
)}
<div className="config-item">
<span className="config-label">温度</span>
<div className="config-value">{app.config?.temperature ?? 'N/A'}</div>
</div>
<div className="config-item">
<span className="config-label">最大Token</span>
<div className="config-value">{app.config?.max_tokens ?? 'N/A'}</div>
</div>
{app.config?.skill_ids?.length > 0 && (
<div className="config-item">
<span className="config-label">绑定技能</span>
<div className="config-value">{app.config.skill_ids.length} 个技能</div>
</div>
)}
{app.config?.knowledge_base_ids?.length > 0 && (
<div className="config-item">
<span className="config-label">绑定知识库</span>
<div className="config-value">{app.config.knowledge_base_ids.length} 个知识库</div>
</div>
)}
</div>
{/* Versions */}
{app.versions && app.versions.length > 0 && (
<>
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>版本历史</h3>
<div className="version-list">
{app.versions.map((v: any) => (
<div key={v.id} className="version-item">
<span className="version-num">v{v.version}</span>
<span className="version-log">{v.changelog}</span>
<span className="version-date">{v.created_at ? new Date(v.created_at).toLocaleDateString('zh-CN') : '-'}</span>
</div>
))}
</div>
</>
)}
{/* Analytics Detail */}
{summary && (
<>
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>分析数据 ({analytics.days}天)</h3>
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)', marginBottom: '16px' }}>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.conversations}</div>
<div className="stat-label">总对话数</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.messages}</div>
<div className="stat-label">总消息数</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.tokens_input + summary.tokens_output}</div>
<div className="stat-label">总Token</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.active_users}</div>
<div className="stat-label">活跃用户</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.avg_response_ms}ms</div>
<div className="stat-label">平均响应</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.error_rate}%</div>
<div className="stat-label">错误率</div>
</div>
</div>
</>
)}
{/* Timestamps */}
<div style={{ marginTop: '24px', fontSize: '13px', color: 'var(--color-text-tertiary)' }}>
<div>创建时间: {app.created_at ? new Date(app.created_at).toLocaleString('zh-CN') : '-'}</div>
<div>更新时间: {app.updated_at ? new Date(app.updated_at).toLocaleString('zh-CN') : '-'}</div>
{app.published_at && <div>发布时间: {new Date(app.published_at).toLocaleString('zh-CN')}</div>}
</div>
</div>
{/* Actions */}
<div className="modal-footer" style={{ justifyContent: 'flex-start' }}>
{app.status === 'draft' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>发布</button>}
{app.status === 'published' && <button className="btn-secondary" onClick={() => onAction(app.id, 'pause')}>暂停</button>}
{app.status === 'paused' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>恢复发布</button>}
<button className="btn-secondary" onClick={() => onAction(app.id, 'duplicate')}>复制应用</button>
<button className="btn-secondary" onClick={() => onAction(app.id, 'archive')}>归档</button>
</div>
</div>
</div>
);
}