feat: Phase 3.5 AI App Management - lifecycle, templates, versioning, analytics
This commit is contained in:
1 parent
e7d1c39c18
commit
6583bd2a62
7 files changed
+2185
-5
No files matched your search
@@ -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()
|
||||||
@@ -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"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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> = {
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user