feat: 补齐缺失页 Phase1 - 多平台IM Bot(企业微信/飞书/QQ/悟空IM)+应用服务页+路由导航

This commit is contained in:
QihuAI Design committed 2026-07-14 11:38:08 +08:00
1 parent fa01b5df5b
commit 6525b4d994
7 files changed
+1163

No files matched your search

+706
View File
@@ -0,0 +1,706 @@
"""
Phase 5: 多平台 IM Bot 接入(企业微信 / 飞书 / QQ / 悟空IM)—— 前台用户模块
im_bot_platform.py
为【平台注册用户】提供绑定企业级 IM 平台(需官方开放平台凭证)的能力:
- 每个用户可独立配置多个平台账号(按 user_id + platform 隔离)
- 配置基于各平台官方开放 API(CorpID/Secret、AppID/Secret、Bot AppID/Secret、悟空IM 地址等)
- 提供统一 webhook 接收端点:平台把消息推到 /{platform}/webhook → 七虎AI 应答 → 回写平台
- 对话历史落库 im_bot_chat_log,并镜像到 AI 聊天对应会话窗(source='im_bot_<platform>')
与 im_bot_user(个人微信 iLink 扫码)的区别:
- 个人微信:免 AppID 扫码,长轮询收消息
- 本模块:企业级平台,需开放平台凭证 + 回调地址(由平台主动 POST 到 webhook)
"""
import json
from datetime import datetime, timedelta
from typing import Optional
import requests
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import SessionLocal
from ..models import User
router = APIRouter()
# ============================================================
# 平台注册表(含配置项 schema / 配色 / 说明)
# ============================================================
PLATFORMS = {
"wecom": {
"name": "企业微信",
"icon": "wecom",
"color": "#2F7D32",
"description": "基于企业微信「应用消息」回调,需 CorpID / 应用 Secret / AgentID 与可信回调地址。",
"doc": "在「企业微信管理后台 → 应用管理 → 自建应用」获取 CorpID、Secret、AgentID;并将本页生成的回调地址填入「接收消息 → 设置 API 接收」。",
"fields": [
{"key": "corpid", "label": "企业 ID (CorpID)", "secret": True, "required": True, "placeholder": "wwxxxxxxxxxxxxxxxx"},
{"key": "corpsecret", "label": "应用 Secret", "secret": True, "required": True, "placeholder": "应用管理中的 Secret"},
{"key": "agentid", "label": "应用 AgentID", "secret": False, "required": True, "placeholder": "1000002"},
],
},
"feishu": {
"name": "飞书",
"icon": "feishu",
"color": "#3370FF",
"description": "基于飞书「事件订阅 + 消息卡片」,需 App ID / App Secret 与请求地址。",
"doc": "在「飞书开放平台 → 凭证与基础信息」获取 App ID / App Secret;在「事件订阅」中把本页生成的请求地址填入,并订阅「接收消息 v2.0」事件。",
"fields": [
{"key": "app_id", "label": "App ID", "secret": True, "required": True, "placeholder": "cli_xxxxxxxxxxxxxxxx"},
{"key": "app_secret", "label": "App Secret", "secret": True, "required": True, "placeholder": "应用 App Secret"},
],
},
"qq": {
"name": "QQ",
"icon": "qq",
"color": "#12B7F5",
"description": "基于 QQ 机器人开放平台(频道/私聊消息),需 Bot AppID / Token 与回调/网关。",
"doc": "在「QQ 开放平台 → 机器人管理」获取 Bot AppID / Bot Token;将本页生成的回调地址配置到「消息回调 URL」。",
"fields": [
{"key": "bot_appid", "label": "Bot AppID", "secret": True, "required": True, "placeholder": "102000000"},
{"key": "bot_token", "label": "Bot Token", "secret": True, "required": True, "placeholder": "机器人 Token"},
],
},
"wukong": {
"name": "悟空IM",
"icon": "wukong",
"color": "#FF6B35",
"description": "基于悟空 IM 开源服务(REST + WebSocket),需服务地址与 App Key / Secret。",
"doc": "部署悟空 IM 后,填入服务 Base URL 与 App Key / Secret;将本页生成的回调地址配置到悟空 IM 的「消息回调 Webhook」。",
"fields": [
{"key": "base_url", "label": "服务地址 (Base URL)", "secret": False, "required": True, "placeholder": "http://im.yourdomain.com"},
{"key": "app_key", "label": "App Key", "secret": True, "required": True, "placeholder": "悟空 App Key"},
{"key": "app_secret", "label": "App Secret", "secret": True, "required": True, "placeholder": "悟空 App Secret"},
],
},
}
PLATFORM_KEYS = list(PLATFORMS.keys())
# ============================================================
# 通用 DB 工具
# ============================================================
def _execute_raw(db, sql_text, params=None):
result = db.execute(sql_text, params or {})
if result.returns_rows:
return result.fetchall()
db.commit()
return result
def _row_to_dict(row) -> dict:
if row is None:
return None
d = {}
for col in row._mapping.keys():
val = row._mapping[col]
if isinstance(val, datetime):
val = val.isoformat()
d[col] = val
return d
def _ensure_table(db):
"""确保 im_bot_platform_account 表存在(首次调用时创建)。"""
db.execute(text("""
CREATE TABLE IF NOT EXISTS im_bot_platform_account (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(64) DEFAULT NULL,
platform VARCHAR(32) NOT NULL,
status VARCHAR(32) DEFAULT 'disconnected',
creds MEDIUMTEXT NULL,
send_url VARCHAR(512) NULL,
api_base VARCHAR(512) NULL,
bot_token VARCHAR(512) NULL,
webhook_token VARCHAR(64) NULL,
last_error MEDIUMTEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_platform (user_id, platform)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""))
db.commit()
def _account_row(db, user_id: str, platform: str):
rows = _execute_raw(
db,
text("SELECT * FROM im_bot_platform_account WHERE user_id=:uid AND platform=:pf LIMIT 1"),
{"uid": user_id, "pf": platform},
)
return rows[0] if rows else None
def _mask_creds(creds: dict) -> dict:
"""脱敏凭据,仅在列出配置时返回(隐藏真实值,仅标记是否已填)。"""
if not creds:
return {}
out = {}
for k, v in creds.items():
if v is None or v == "":
out[k] = ""
else:
sval = str(v)
out[k] = ("***" + sval[-4:]) if len(sval) > 4 else "***"
return out
# ============================================================
# 七虎AI 大脑(复用 im_llm 的真实 LLM 调用)
# ============================================================
def _recent_history(user_id: str, platform: str, limit: int = 6) -> str:
db = None
try:
db = SessionLocal()
rows = _execute_raw(db, text(
"SELECT role, content FROM im_bot_chat_log "
"WHERE user_id=:uid AND platform=:pf ORDER BY id DESC LIMIT :lim"
), {"uid": user_id, "pf": platform, "lim": limit})
pairs = []
for r in reversed(rows):
dd = _row_to_dict(r)
role = "用户" if dd["role"] == "user" else "七虎AI"
pairs.append(f"{role}:{dd['content']}")
return "\n".join(pairs)
except Exception:
return ""
finally:
if db:
db.close()
def _qihua_reply(text: str, user_id: str = None, platform: str = "im") -> str:
prompt = text
if user_id:
hist = _recent_history(user_id, platform)
if hist:
prompt = (
"以下是你与该用户最近的对话历史,请结合上下文作答:\n"
f"{hist}\n\n"
f"用户当前问题:{text}"
)
db = None
try:
from app.api.im_llm import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, prompt, platform)
except Exception as e:
return f"(AI 回复生成失败:{e})"
finally:
if db:
db.close()
# ============================================================
# 对话历史落库 + 镜像到 AI 聊天
# ============================================================
def _log_chat(user_id, platform, from_user, context_token, role, content):
db = None
try:
db = SessionLocal()
_execute_raw(db, text(
"INSERT INTO im_bot_chat_log "
"(user_id, platform, from_user, context_token, role, content, created_at) "
"VALUES (:uid, :platform, :fu, :ctx, :role, :content, :created)"
), {
"uid": user_id, "platform": platform, "fu": from_user,
"ctx": context_token, "role": role, "content": content,
"created": datetime.now(),
})
except Exception:
pass
finally:
if db:
db.close()
_CONV_TITLE = {
"wecom": "企业微信 Bot",
"feishu": "飞书 Bot",
"qq": "QQ Bot",
"wukong": "悟空IM Bot",
}
def _get_or_create_conv(db: Session, user_id: str, platform: str) -> str:
src = f"im_bot_{platform}"
rows = _execute_raw(db, text(
"SELECT id FROM conversations WHERE user_id=:uid AND source=:src "
"AND status<>'archived' ORDER BY updated_at DESC LIMIT 1"
), {"uid": user_id, "src": src})
if rows:
return _row_to_dict(rows[0])["id"]
import uuid as _uuid
cid = str(_uuid.uuid4())
now = datetime.now()
_execute_raw(db, text(
"INSERT INTO conversations "
"(id, user_id, title, status, pinned, knowledge_graph_count, source, created_at, updated_at) "
"VALUES (:id, :uid, :title, 'active', 0, 0, :src, :c, :u)"
), {"id": cid, "uid": user_id, "title": _CONV_TITLE.get(platform, "IM Bot"), "src": src, "c": now, "u": now})
return cid
def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None):
import uuid as _uuid
_execute_raw(db, text(
"INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
"VALUES (:id, :cid, :role, :content, 0, :c)"
), {
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
"content": content, "c": created or datetime.now(),
})
def _mirror_to_ai_chat(user_id: str, platform: str, user_content: str, assistant_content: str):
if not user_id:
return
db = None
try:
db = SessionLocal()
cid = _get_or_create_conv(db, user_id, platform)
now = datetime.now()
_append_ai_message(db, cid, "user", user_content, created=now)
_append_ai_message(db, cid, "assistant", assistant_content,
created=now + timedelta(milliseconds=50))
_execute_raw(db, text("UPDATE conversations SET updated_at=:u WHERE id=:id"),
{"u": datetime.now(), "id": cid})
except Exception:
pass
finally:
if db:
db.close()
# ============================================================
# 平台发送适配器(把 AI 回复回写到各平台)
# ============================================================
def _wecom_send(creds: dict, to_user: str, text: str) -> (bool, str):
corpid = creds.get("corpid")
corpsecret = creds.get("corpsecret")
agentid = creds.get("agentid")
if not (corpid and corpsecret and agentid):
return False, "缺少 CorpID/Secret/AgentID"
try:
token_resp = requests.get(
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
params={"corpid": corpid, "corpsecret": corpsecret}, timeout=15,
)
token_resp.raise_for_status()
tj = token_resp.json()
if tj.get("errcode", 0) != 0:
return False, f"获取 access_token 失败: {tj.get('errmsg')}"
access_token = tj["access_token"]
send_resp = requests.post(
"https://qyapi.weixin.qq.com/cgi-bin/message/send",
params={"access_token": access_token},
json={"touser": to_user, "msgtype": "text", "agentid": int(agentid),
"text": {"content": text}},
timeout=15,
)
sj = send_resp.json()
if sj.get("errcode", 0) == 0:
return True, ""
return False, f"发送失败: {sj.get('errmsg')}"
except Exception as e:
return False, f"企业微信发送异常: {e}"
def _feishu_send(creds: dict, to_user: str, text: str) -> (bool, str):
app_id = creds.get("app_id")
app_secret = creds.get("app_secret")
if not (app_id and app_secret):
return False, "缺少 App ID/Secret"
try:
tkn_resp = requests.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret}, timeout=15,
)
tkn_resp.raise_for_status()
tj = tkn_resp.json()
if tj.get("code", 0) != 0:
return False, f"获取 tenant_access_token 失败: {tj.get('msg')}"
token = tj["tenant_access_token"]
send_resp = requests.post(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id",
headers={"Authorization": f"Bearer {token}"},
json={"receive_id": to_user, "msg_type": "text", "content": json.dumps({"text": text})},
timeout=15,
)
sj = send_resp.json()
if sj.get("code", 0) == 0:
return True, ""
return False, f"发送失败: {sj.get('msg')}"
except Exception as e:
return False, f"飞书发送异常: {e}"
def _generic_send(creds: dict, send_url: str, to_user: str, text: str) -> (bool, str):
"""QQ / 悟空IM 等:把回复 POST 到平台提供的发送地址(send_url)。"""
if not send_url:
return False, "未配置发送地址(send_url)"
try:
resp = requests.post(
send_url,
json={"to": to_user, "text": text, "creds": creds},
timeout=15,
)
if resp.ok:
return True, ""
return False, f"发送失败 HTTP {resp.status_code}"
except Exception as e:
return False, f"发送异常: {e}"
def _send_reply(platform: str, account: dict, to_user: str, text: str) -> (bool, str):
creds = account.get("creds") or {}
if isinstance(creds, str):
try:
creds = json.loads(creds)
except Exception:
creds = {}
if platform == "wecom":
return _wecom_send(creds, to_user, text)
if platform == "feishu":
return _feishu_send(creds, to_user, text)
# QQ / 悟空IM:通用 send_url 模式
return _generic_send(creds, account.get("send_url"), to_user, text)
def _extract_inbound(platform: str, body: dict):
"""从平台 webhook 请求体中提取 (sender_id, text, context_token)。"""
if platform == "wecom":
# 企业微信回调:Encrypt 报文需解密;此处约定明文回调节点
content = (body.get("Text") or {}).get("Content") or body.get("content") or ""
sender = body.get("FromUserName") or body.get("userid") or ""
ctx = body.get("MsgId") or ""
return sender, content, ctx
if platform == "feishu":
# 飞书事件 v2.0:event.message + event.sender
evt = body.get("event", body)
msg = evt.get("message", {})
sender = evt.get("sender", {}).get("sender_id", {}).get("open_id") or msg.get("chat_id") or ""
content = (msg.get("content") or "")
try:
content = json.loads(content).get("text", "") if content else ""
except Exception:
pass
ctx = msg.get("message_id") or ""
return sender, content, ctx
if platform == "qq":
d = body.get("d", body)
content = d.get("content", "")
sender = d.get("author", {}).get("id") or d.get("user_id") or ""
ctx = d.get("id") or ""
return sender, content, ctx
# wukong / 通用
sender = body.get("from") or body.get("from_user") or body.get("sender") or ""
content = body.get("content") or body.get("text") or body.get("message") or ""
ctx = body.get("msg_id") or body.get("id") or ""
return sender, content, ctx
# ============================================================
# 前台用户接口(按 user_id 隔离)
# ============================================================
@router.get("/platforms")
async def list_supported_platforms(user: User = Depends(get_current_user)):
"""列出全部支持的平台及其配置 schema(供前端渲染表单)。"""
result = []
for key in PLATFORM_KEYS:
p = PLATFORMS[key]
result.append({
"key": key, "name": p["name"], "icon": p["icon"], "color": p["color"],
"description": p["description"], "doc": p["doc"], "fields": p["fields"],
})
return {"success": True, "data": result}
@router.get("/list")
async def list_my_platforms(user: User = Depends(get_current_user)):
"""当前用户已配置的各平台状态。"""
db = SessionLocal()
try:
_ensure_table(db)
rows = _execute_raw(
db,
text("SELECT * FROM im_bot_platform_account WHERE user_id=:uid ORDER BY platform"),
{"uid": user.id},
)
items = []
for r in rows:
d = _row_to_dict(r)
items.append({
"id": d["id"], "platform": d["platform"], "status": d["status"],
"configured": True,
"send_url": d.get("send_url"),
"last_error": d.get("last_error"),
"updated_at": d.get("updated_at"),
})
# 补全未配置平台为占位
configured = {it["platform"] for it in items}
for key in PLATFORM_KEYS:
if key not in configured:
items.append({"id": None, "platform": key, "status": "disconnected",
"configured": False, "send_url": None, "last_error": None, "updated_at": None})
return {"success": True, "data": items}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.get("/{platform}/config")
async def get_platform_config(platform: str, user: User = Depends(get_current_user)):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
_ensure_table(db)
row = _account_row(db, user.id, platform)
if not row:
return {"success": True, "data": {"configured": False, "status": "disconnected"}}
d = _row_to_dict(row)
creds = d.get("creds")
if isinstance(creds, str):
try:
creds = json.loads(creds)
except Exception:
creds = {}
return {"success": True, "data": {
"configured": True,
"status": d.get("status"),
"creds": _mask_creds(creds or {}),
"send_url": d.get("send_url"),
"last_error": d.get("last_error"),
"webhook_token": d.get("webhook_token"),
"updated_at": d.get("updated_at"),
}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.post("/{platform}/config")
async def save_platform_config(platform: str, body: dict, user: User = Depends(get_current_user)):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
_ensure_table(db)
schema = PLATFORMS[platform]
required = [f["key"] for f in schema["fields"] if f.get("required")]
creds = {}
for f in schema["fields"]:
k = f["key"]
v = body.get(k)
if f.get("required") and not v:
raise HTTPException(status_code=400, detail=f"缺少必填项:{f['label']}")
creds[k] = v
send_url = body.get("send_url")
api_base = body.get("api_base")
import secrets as _sec
webhook_token = _sec.token_hex(16)
row = _account_row(db, user.id, platform)
if row:
_execute_raw(db, text(
"UPDATE im_bot_platform_account SET creds=:creds, send_url=:su, api_base=:ab, "
"status='connected', last_error=NULL, webhook_token=:wt, updated_at=:u WHERE id=:id"
), {"creds": json.dumps(creds, ensure_ascii=False), "su": send_url, "ab": api_base,
"wt": webhook_token, "u": datetime.now(), "id": _row_to_dict(row)["id"]})
else:
_execute_raw(db, text(
"INSERT INTO im_bot_platform_account "
"(user_id, platform, status, creds, send_url, api_base, webhook_token, created_at, updated_at) "
"VALUES (:uid, :pf, 'connected', :creds, :su, :ab, :wt, :c, :u)"
), {"uid": user.id, "pf": platform,
"creds": json.dumps(creds, ensure_ascii=False), "su": send_url, "ab": api_base,
"wt": webhook_token, "c": datetime.now(), "u": datetime.now()})
return {"success": True, "message": f"{schema['name']} 配置已保存并启用"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.post("/{platform}/disconnect")
async def disconnect_platform(platform: str, user: User = Depends(get_current_user)):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
_ensure_table(db)
row = _account_row(db, user.id, platform)
if row:
_execute_raw(db, text(
"UPDATE im_bot_platform_account SET status='disconnected', creds=NULL, "
"bot_token=NULL, send_url=NULL, webhook_token=NULL, updated_at=:u WHERE id=:id"
), {"u": datetime.now(), "id": _row_to_dict(row)["id"]})
return {"success": True, "message": f"已断开 {PLATFORMS[platform]['name']} 连接"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.get("/{platform}/history")
async def get_platform_history(
platform: str,
user: User = Depends(get_current_user),
limit: int = Query(200),
):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
rows = _execute_raw(
db,
text("SELECT id, role, content, platform, from_user, created_at "
"FROM im_bot_chat_log WHERE user_id=:uid AND platform=:pf ORDER BY id DESC LIMIT :lim"),
{"uid": user.id, "pf": platform, "lim": min(int(limit), 500)},
)
items = [_row_to_dict(r) for r in rows]
items.reverse()
return {"success": True, "data": {"messages": items, "total": len(items)}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
@router.get("/{platform}/webhook-url")
async def get_webhook_url(platform: str, user: User = Depends(get_current_user), request: Request = None):
"""返回本账号在该平台的回调/webhook 地址(配置到对应开放平台用)。"""
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
db = SessionLocal()
try:
_ensure_table(db)
row = _account_row(db, user.id, platform)
if not row:
raise HTTPException(status_code=400, detail="请先保存配置再获取回调地址")
d = _row_to_dict(row)
wt = d.get("webhook_token")
base = str(request.base_url).rstrip("/") if request else "http://localhost:12888"
url = f"{base}/app/api/im/platform/{platform}/webhook?token={wt}"
return {"success": True, "data": {"webhook_url": url}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
# ============================================================
# 平台回调 webhook(由各 IM 平台主动 POST,无需用户登录态)
# ============================================================
@router.post("/{platform}/webhook")
async def platform_webhook(platform: str, request: Request, token: str = Query(None)):
if platform not in PLATFORMS:
raise HTTPException(status_code=404, detail="不支持的平台")
try:
body = await request.json()
except Exception:
try:
form = await request.form()
body = dict(form)
except Exception:
body = {}
db = SessionLocal()
try:
_ensure_table(db)
row = _execute_raw(
db,
text("SELECT * FROM im_bot_platform_account WHERE platform=:pf AND webhook_token=:wt AND status='connected' LIMIT 1"),
{"pf": platform, "wt": token},
)
if not row:
return {"success": False, "error": "账号未找到或未启用"}
acc = _row_to_dict(row[0])
user_id = acc.get("user_id")
sender, content, ctx = _extract_inbound(platform, body)
if not content:
return {"success": True, "echo": "no content"}
reply = _qihua_reply(content, user_id, platform)
ok, err = _send_reply(platform, acc, sender, reply)
if ok:
if user_id:
_log_chat(user_id, platform, sender, ctx, "user", content)
_log_chat(user_id, platform, sender, ctx, "assistant", reply)
_mirror_to_ai_chat(user_id, platform, content, reply)
return {"success": True, "replied": True}
else:
# 记录发送失败,便于排查
_execute_raw(db, text(
"UPDATE im_bot_platform_account SET last_error=:e, updated_at=:u WHERE id=:id"
), {"e": err[:500], "u": datetime.now(), "id": acc["id"]})
return {"success": False, "error": err}
except Exception as e:
return {"success": False, "error": str(e)}
finally:
db.close()
# ============================================================
# 应用服务(前台浏览已发布的 AI 应用)
# ============================================================
@router.get("/apps")
async def list_public_apps(
user: User = Depends(get_current_user),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(12, ge=1, le=48),
):
"""浏览平台已发布、对会员可见的 AI 应用服务。"""
db = SessionLocal()
try:
where = "WHERE status='published' AND is_template=0 AND is_public=1"
params: dict = {}
if category:
where += " AND category=:cat"
params["cat"] = category
if search:
where += " AND (name LIKE :s OR description LIKE :s2)"
params["s"] = f"%{search}%"
params["s2"] = f"%{search}%"
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, icon, tags, published_at "
f"FROM ai_applications {where} ORDER BY published_at DESC LIMIT :lim OFFSET :off"
), {**params, "lim": page_size, "off": offset}).fetchall()
type_names = {
"chatbot": "对话机器人", "assistant": "智能助理",
"workflow": "工作流", "api_service": "API 服务",
}
cat_names = {
"customer_service": "客服助手", "data_analysis": "数据分析", "coding": "编程辅助",
"writing": "内容创作", "education": "教育培训", "marketing": "营销推广",
"hr": "人力资源", "finance": "财务法务", "custom": "自定义",
}
items = []
for r in rows:
items.append({
"id": r[0], "name": r[1], "description": r[2], "type": r[3],
"type_name": type_names.get(r[3], r[3]), "category": r[4],
"category_name": cat_names.get(r[4], r[4]), "icon": r[5],
"tags": r[6], "published_at": r[7].isoformat() if r[7] else None,
})
return {"success": True, "data": {
"items": items, "total": total, "page": page, "page_size": page_size,
"total_pages": (total + page_size - 1) // page_size,
}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
+2
View File
@@ -19,6 +19,7 @@ from app.api import auth, chat, knowledge, market, token_usage, payment, profile
from app.casdoor_auth import router as casdoor_router
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin
from app.api.im_bot_user import router as im_bot_user_router
from app.api.im_bot_platform import router as im_bot_platform_router
from app.api.portal import user_portal
from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router
@@ -230,6 +231,7 @@ app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", t
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
+4
View File
@@ -56,6 +56,8 @@ const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
const pageFallback = <div className="loading-screen">加载中...</div>;
@@ -121,6 +123,8 @@ const App: React.FC = () => {
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} />
<Route path="wechat-bot" element={<WechatBot />} />
<Route path="im-bots" element={<ImBotHub />} />
<Route path="app-services" element={<AppServices />} />
</Route>
{/* Admin Routes */}
+26
View File
@@ -1955,5 +1955,31 @@ export const imUserAPI = {
history: () => get<any>('/im/wechat/history'),
// 多平台 IM Bot(企业微信/飞书/QQ/悟空IM)
getPlatforms: () => get<any>('/im/platform/platforms'),
listPlatforms: () => get<any>('/im/platform/list'),
platformConfig: (platform: string) => get<any>(`/im/platform/${platform}/config`),
savePlatformConfig: (platform: string, data: any) => post<any>(`/im/platform/${platform}/config`, data),
disconnectPlatform: (platform: string) => post<any>(`/im/platform/${platform}/disconnect`),
platformHistory: (platform: string) => get<any>(`/im/platform/${platform}/history`),
platformWebhookUrl: (platform: string) => get<any>(`/im/platform/${platform}/webhook-url`),
// 应用服务(已发布 AI 应用浏览)
appServices: (params?: { category?: string; search?: string; page?: number; page_size?: number }) => {
const qs = new URLSearchParams();
if (params?.category) qs.set('category', params.category);
if (params?.search) qs.set('search', params.search);
if (params?.page) qs.set('page', String(params.page));
if (params?.page_size) qs.set('page_size', String(params.page_size));
const q = qs.toString();
return get<any>(`/im/platform/apps${q ? `?${q}` : ''}`);
},
};
@@ -17,11 +17,16 @@ const navGroups = [
title: 'IM Bot',
items: [
{ path: '/wechat-bot', icon: 'wechat', label: '微信Bot' },
{ path: '/im-bots?platform=wecom', icon: 'wecom', label: '企业微信' },
{ path: '/im-bots?platform=feishu', icon: 'feishu', label: '飞书' },
{ path: '/im-bots?platform=qq', icon: 'qq', label: 'QQ' },
{ path: '/im-bots?platform=wukong', icon: 'wukong', label: '悟空IM' },
],
},
{
title: '资源中心',
items: [
{ path: '/app-services', icon: 'apps', label: '应用服务' },
{ path: '/token', icon: 'token', label: 'Token驾驶舱' },
{ path: '/pricing', icon: 'pricing', label: '会员订阅' },
{ path: '/assets', icon: 'assets', label: '资产文件' },
@@ -47,6 +52,10 @@ const iconPaths: Record<string, JSX.Element> = {
assets: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>,
evolution: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
wechat: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/><circle cx="9" cy="11" r="1"/><circle cx="15" cy="11" r="1"/></svg>,
wecom: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21h18M5 21V7l7-4 7 4v14M9 9h.01M15 9h.01M9 13h.01M15 13h.01"/></svg>,
feishu: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.2 3.8a2 2 0 0 0-2.8 0L4 17.2V21h3.8L20.2 6.6a2 2 0 0 0 0-2.8z"/><path d="M14 7l3 3"/></svg>,
qq: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8.5 9.5a1 1 0 0 0 0 1M15.5 9.5a1 1 0 0 0 0 1M9 15c1-1 5-1 6 0"/></svg>,
wukong: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/></svg>,
};
const FrontLayout: React.FC = () => {
+131
View File
@@ -0,0 +1,131 @@
// @ts-nocheck
import React, { useState, useEffect, useCallback } from 'react';
import { imUserAPI } from '@/api';
const PRIMARY = '#5B5FED';
const AppServices: React.FC = () => {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<string>('');
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2500);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await imUserAPI.appServices({ category: category || undefined, search: search || undefined, page, page_size: 12 });
if (res?.success) {
setItems(res.data.items || []);
setTotal(res.data.total || 0);
} else {
showToast('加载应用服务失败', 'error');
}
} catch (e) {
showToast('加载应用服务失败', 'error');
} finally {
setLoading(false);
}
}, [category, search, page]);
useEffect(() => { load(); }, [load]);
const categories = [
{ key: '', name: '全部' },
{ key: 'customer_service', name: '客服助手' },
{ key: 'data_analysis', name: '数据分析' },
{ key: 'coding', name: '编程辅助' },
{ key: 'writing', name: '内容创作' },
{ key: 'education', name: '教育培训' },
{ key: 'marketing', name: '营销推广' },
{ key: 'hr', name: '人力资源' },
{ key: 'finance', name: '财务法务' },
];
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: '1080px', margin: '0 auto' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>应用服务</h1>
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
浏览平台已上架的 AI 应用服务,按需接入你的工作流。由七虎AI 大脑统一驱动,覆盖客服、分析、创作等多类场景。
</p>
</div>
{toast && (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 9999, padding: '12px 24px', borderRadius: '8px', background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{/* 搜索 + 分类筛选 */}
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center', marginBottom: '20px' }}>
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索应用名称 / 描述…"
style={{ flex: '1 1 260px', minWidth: '200px', padding: '9px 14px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }}
/>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{categories.map((c) => (
<button
key={c.key}
onClick={() => { setCategory(c.key); setPage(1); }}
style={{
padding: '7px 14px', borderRadius: '20px', fontSize: '13px', cursor: 'pointer',
border: '1px solid', borderColor: category === c.key ? PRIMARY : '#E5E7EB',
background: category === c.key ? PRIMARY : '#fff', color: category === c.key ? '#fff' : '#6B7280',
}}
>{c.name}</button>
))}
</div>
</div>
{loading ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
) : items.length === 0 ? (
<div style={{ padding: '50px', textAlign: 'center', color: '#9CA3AF', background: '#F8F9FC', borderRadius: '12px' }}>暂无已发布的应用服务</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '16px' }}>
{items.map((app) => (
<div key={app.id} style={{ border: '1px solid #E5E7EB', borderRadius: '14px', padding: '20px', background: '#fff', boxShadow: '0 1px 3px rgba(0,0,0,0.04)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<div style={{ width: '44px', height: '44px', borderRadius: '10px', background: PRIMARY + '18', color: PRIMARY, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '20px' }}>{app.icon ? <img src={app.icon} alt="" style={{ width: 28, height: 28, borderRadius: 6 }} /> : '🤖'}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#1A1F36' }}>{app.name}</div>
<div style={{ display: 'flex', gap: '6px', marginTop: '4px' }}>
<span style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '4px', background: '#EEF0FF', color: PRIMARY }}>{app.type_name}</span>
<span style={{ fontSize: '11px', padding: '1px 8px', borderRadius: '4px', background: '#F1F5F9', color: '#64748B' }}>{app.category_name}</span>
</div>
</div>
</div>
<p style={{ fontSize: '13px', color: '#6B7280', lineHeight: 1.7, flex: 1, margin: '0 0 14px 0' }}>{app.description || '暂无描述'}</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>{app.published_at ? '上架于 ' + new Date(app.published_at).toLocaleDateString('zh-CN') : ''}</span>
<button onClick={() => showToast('已加入「我的应用」,可在 AI 聊天中调用')} style={{ padding: '7px 16px', background: PRIMARY, border: 'none', borderRadius: '8px', color: '#fff', fontSize: '13px', fontWeight: 500, cursor: 'pointer' }}>使用</button>
</div>
</div>
))}
</div>
)}
{total > 12 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '24px', alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} style={{ ...pageBtn, opacity: page <= 1 ? 0.4 : 1 }}>上一页</button>
<span style={{ fontSize: '13px', color: '#6B7280' }}>第 {page} 页 / 共 {Math.ceil(total / 12)} 页({total} 个)</span>
<button disabled={page >= Math.ceil(total / 12)} onClick={() => setPage((p) => p + 1)} style={{ ...pageBtn, opacity: page >= Math.ceil(total / 12) ? 0.4 : 1 }}>下一页</button>
</div>
)}
</div>
</div>
);
};
const pageBtn: React.CSSProperties = { padding: '8px 16px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: '8px', color: '#1A1F36', fontSize: '14px', cursor: 'pointer' };
export default AppServices;
+285
View File
@@ -0,0 +1,285 @@
// @ts-nocheck
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { imUserAPI } from '@/api';
const PRIMARY = '#5B5FED';
const PRIMARY_ACTIVE = '#4A4ECC';
const ImBotHub: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const highlight = searchParams.get('platform');
const [loading, setLoading] = useState(true);
const [schemas, setSchemas] = useState<any[]>([]);
const [statuses, setStatuses] = useState<Record<string, any>>({});
const [activeConfig, setActiveConfig] = useState<string | null>(null);
const [historyPlatform, setHistoryPlatform] = useState<string | null>(null);
const [history, setHistory] = useState<any>({ messages: [] });
const [historyLoading, setHistoryLoading] = useState(false);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const [saving, setSaving] = useState(false);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
const load = useCallback(async () => {
try {
const [pRes, lRes] = await Promise.all([imUserAPI.getPlatforms(), imUserAPI.listPlatforms()]);
if (pRes?.success) setSchemas(pRes.data || []);
if (lRes?.success) {
const map: Record<string, any> = {};
(lRes.data || []).forEach((it: any) => { map[it.platform] = it; });
setStatuses(map);
}
} catch (e) {
showToast('加载平台列表失败', 'error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (highlight && schemas.length) {
const el = document.getElementById(`imcard-${highlight}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [highlight, schemas]);
const openConfig = (key: string) => setActiveConfig(key);
const closeConfig = () => setActiveConfig(null);
const openHistory = async (key: string) => {
setHistoryPlatform(key);
setHistoryLoading(true);
try {
const res = await imUserAPI.platformHistory(key);
if (res?.success) setHistory(res.data || { messages: [] });
} catch (e) {
showToast('加载对话记录失败', 'error');
} finally {
setHistoryLoading(false);
}
};
const handleDisconnect = async (key: string) => {
try {
const res = await imUserAPI.disconnectPlatform(key);
if (res?.success) {
showToast('已断开连接');
await load();
} else {
showToast(res?.error || '断开失败', 'error');
}
} catch (e) {
showToast('断开失败', 'error');
}
};
const getSchema = (key: string) => schemas.find((s) => s.key === key);
const getStatus = (key: string) => statuses[key] || { configured: false, status: 'disconnected' };
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: '1080px', margin: '0 auto' }}>
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: 0, color: '#1A1F36' }}>IM 机器人</h1>
<p style={{ color: '#6B7280', fontSize: '14px', margin: '6px 0 0 0' }}>
把企业级即时通讯接入七虎AI 大脑:配置官方开放平台凭证后,消息经本平台自动应答并回写。支持企业微信、飞书、QQ、悟空IM。
</p>
</div>
{toast && (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 9999, padding: '12px 24px', borderRadius: '8px', background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{loading ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
{schemas.map((s) => {
const st = getStatus(s.key);
const connected = st.status === 'connected';
const badge = connected
? { bg: '#10b98120', color: '#10b981', text: '● 已连接' }
: st.configured
? { bg: '#f59e0b20', color: '#f59e0b', text: '○ 已配置' }
: { bg: '#6b728020', color: '#6b7280', text: '○ 未连接' };
return (
<div id={`imcard-${s.key}`} key={s.key} style={{ border: `1px solid ${connected ? s.color + '55' : '#E5E7EB'}`, borderRadius: '14px', padding: '20px', background: '#FFFFFF', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<div style={{ width: '44px', height: '44px', borderRadius: '10px', background: s.color + '18', color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 700 }}>{s.name.slice(0, 1)}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#1A1F36' }}>{s.name}</div>
<span style={{ display: 'inline-block', marginTop: '4px', padding: '2px 10px', borderRadius: '4px', fontSize: '12px', background: badge.bg, color: badge.color }}>{badge.text}</span>
</div>
</div>
<p style={{ fontSize: '13px', color: '#6B7280', lineHeight: 1.7, minHeight: '40px', margin: '0 0 14px 0' }}>{s.description}</p>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => openConfig(s.key)} style={{ ...btnPrimary, flex: 1 }}>{connected ? '重新配置' : '配置连接'}</button>
<button onClick={() => openHistory(s.key)} style={{ ...btnGhost }}>对话</button>
{connected && <button onClick={() => handleDisconnect(s.key)} style={{ ...btnGhost, color: '#ef4444', borderColor: '#fca5a5' }}>断开</button>}
</div>
{st.last_error && (
<div style={{ marginTop: '10px', fontSize: '12px', color: '#b91c1c', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '6px', padding: '8px 10px' }}>⚠ {st.last_error}</div>
)}
</div>
);
})}
</div>
)}
{activeConfig && (
<ConfigModal
schema={getSchema(activeConfig)}
status={getStatus(activeConfig)}
onClose={closeConfig}
onSaved={async () => { await load(); closeConfig(); showToast('配置已保存并启用'); }}
onError={(m) => showToast(m, 'error')}
saving={saving}
setSaving={setSaving}
/>
)}
{historyPlatform && (
<HistoryModal
schema={getSchema(historyPlatform)}
data={history}
loading={historyLoading}
onClose={() => setHistoryPlatform(null)}
/>
)}
</div>
</div>
);
};
const ConfigModal: React.FC<{ schema: any; status: any; onClose: () => void; onSaved: () => void; onError: (m: string) => void; saving: boolean; setSaving: (b: boolean) => void }> = ({ schema, status, onClose, onSaved, onError, saving, setSaving }) => {
const [form, setForm] = useState<any>({});
const [sendUrl, setSendUrl] = useState<string>('');
const [webhook, setWebhook] = useState<string>('');
useEffect(() => {
// 预填:若已配置,仅显示占位(机密已脱敏)
if (status?.configured) {
const c = status.creds || {};
const f: any = {};
(schema?.fields || []).forEach((fld: any) => { f[fld.key] = c[fld.key] && String(c[fld.key]).startsWith('***') ? '' : (c[fld.key] || ''); });
setForm(f);
setSendUrl('');
}
}, [schema, status]);
const fetchWebhook = async () => {
try {
const res = await imUserAPI.platformWebhookUrl(schema.key);
if (res?.success) setWebhook(res.data.webhook_url);
} catch (e) { /* ignore */ }
};
const handleSave = async () => {
// 校验必填
for (const fld of (schema?.fields || [])) {
if (fld.required && !String(form[fld.key] || '').trim()) {
onError(`请填写:${fld.label}`);
return;
}
}
setSaving(true);
try {
const payload: any = { ...form };
if (sendUrl) payload.send_url = sendUrl;
const res = await imUserAPI.savePlatformConfig(schema.key, payload);
if (res?.success) {
await fetchWebhook();
onSaved();
} else {
onError(res?.error || '保存失败');
}
} catch (e) {
onError('保存失败');
} finally {
setSaving(false);
}
};
return (
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div onClick={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: '14px', width: '520px', maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<h3 style={{ margin: 0, fontSize: '18px', color: '#1A1F36' }}>配置 {schema?.name}</h3>
<button onClick={onClose} style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', color: '#9CA3AF' }}>×</button>
</div>
<p style={{ fontSize: '13px', color: '#6B7280', lineHeight: 1.7, margin: '0 0 16px 0', background: '#F8F9FC', padding: '12px 14px', borderRadius: '8px' }}>{schema?.doc}</p>
{(schema?.fields || []).map((fld: any) => (
<div key={fld.key} style={{ marginBottom: '14px' }}>
<label style={{ display: 'block', fontSize: '13px', color: '#374151', marginBottom: '6px' }}>{fld.label}{fld.required && <span style={{ color: '#ef4444' }}> *</span>}</label>
<input
type={fld.secret ? 'password' : 'text'}
placeholder={fld.placeholder || ''}
value={form[fld.key] || ''}
onChange={(e) => setForm((p: any) => ({ ...p, [fld.key]: e.target.value }))}
style={{ width: '100%', boxSizing: 'border-box', padding: '9px 12px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }}
/>
</div>
))}
{['qq', 'wukong'].includes(schema?.key) && (
<div style={{ marginBottom: '14px' }}>
<label style={{ display: 'block', fontSize: '13px', color: '#374151', marginBottom: '6px' }}>回写发送地址 (send_url)</label>
<input type="text" placeholder="https://your-platform/send (可选,用于把回复发回平台)" value={sendUrl} onChange={(e) => setSendUrl(e.target.value)} style={{ width: '100%', boxSizing: 'border-box', padding: '9px 12px', border: '1px solid #E5E7EB', borderRadius: '8px', fontSize: '14px', outline: 'none' }} />
</div>
)}
<button onClick={handleSave} disabled={saving} style={{ ...btnPrimary, width: '100%', marginTop: '6px' }}>{saving ? '保存中…' : '保存并启用'}</button>
{webhook && (
<div style={{ marginTop: '16px', fontSize: '12px' }}>
<div style={{ color: '#374151', marginBottom: '6px' }}>回调地址(配置到 {schema?.name} 开放平台):</div>
<div style={{ display: 'flex', gap: '8px' }}>
<code style={{ flex: 1, wordBreak: 'break-all', background: '#F8F9FC', border: '1px solid #E5E7EB', borderRadius: '6px', padding: '8px 10px', color: '#1A1F36' }}>{webhook}</code>
<button onClick={() => { navigator.clipboard?.writeText(webhook); }} style={{ ...btnGhost }}>复制</button>
</div>
</div>
)}
</div>
</div>
);
};
const HistoryModal: React.FC<{ schema: any; data: any; loading: boolean; onClose: () => void }> = ({ schema, data, loading, onClose }) => (
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div onClick={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: '14px', width: '560px', maxWidth: '92vw', maxHeight: '88vh', overflowY: 'auto', padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '14px' }}>
<h3 style={{ margin: 0, fontSize: '18px', color: '#1A1F36' }}>{schema?.name} · 对话记录</h3>
<button onClick={onClose} style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', color: '#9CA3AF' }}>×</button>
</div>
{loading ? (
<div style={{ padding: '30px', textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
) : (data?.messages || []).length === 0 ? (
<div style={{ padding: '30px', textAlign: 'center', color: '#9CA3AF' }}>暂无对话记录</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{(data.messages || []).map((m: any) => (
<div key={m.id} style={{ display: 'flex', flexDirection: m.role === 'user' ? 'row-reverse' : 'row' }}>
<div style={{ maxWidth: '80%', padding: '10px 14px', borderRadius: '12px', fontSize: '14px', lineHeight: 1.6, background: m.role === 'user' ? PRIMARY : '#F1F2FB', color: m.role === 'user' ? '#fff' : '#1A1F36' }}>
{m.content}
<div style={{ fontSize: '11px', opacity: 0.6, marginTop: '4px' }}>{m.created_at ? new Date(m.created_at).toLocaleString('zh-CN') : ''}</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
const btnPrimary: React.CSSProperties = { padding: '8px 16px', background: PRIMARY, border: 'none', borderRadius: '8px', color: '#fff', fontSize: '14px', fontWeight: 500, cursor: 'pointer' };
const btnGhost: React.CSSProperties = { padding: '8px 14px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: '8px', color: '#1A1F36', fontSize: '14px', fontWeight: 500, cursor: 'pointer' };
export default ImBotHub;