Compare commits
21
Commits
d2d133f22a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f3edac0a | ||
|
|
7b8c3ba2dd | ||
|
|
8f30a588fb | ||
|
|
1ba81ba0d1 | ||
|
|
66f696c1e6 | ||
|
|
09440c2577 | ||
|
|
8a9c2e4868 | ||
|
|
39fcdbf6da | ||
|
|
910394423d | ||
|
|
4a2920e637 | ||
|
|
6f192a89cf | ||
|
|
8f57956904 | ||
|
|
552a962123 | ||
|
|
ab11283d92 | ||
|
|
f975672b0e | ||
|
|
9cf4378bf5 | ||
|
|
1c0dc59842 | ||
|
|
51d8df6e6a | ||
|
|
e284d43cc7 | ||
|
|
46e9f1e1e6 | ||
|
|
894eeff65a |
No files matched your search
@@ -14,7 +14,15 @@ import hmac
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# #976 全平台统一使用北京时间(容器 UTC,前端按 +08:00 展示)
|
||||
_TZ_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _beijing_now():
|
||||
"""返回带时区的北京时间(aware datetime,存 PG timestamptz 不误解)。"""
|
||||
return datetime.now(_TZ_BEIJING)
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -773,13 +781,13 @@ async def wechat_qrcode_status(qrcode: str = Query(...), user=Depends(get_curren
|
||||
(bot_token, ilink_bot_id, baseurl, status, connected_at, created_at, updated_at)
|
||||
VALUES (:bot_token, :ilink_bot_id, :baseurl, 'connected', :connected_at, :created_at, :updated_at)"""),
|
||||
{"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl,
|
||||
"connected_at": datetime.now(), "created_at": datetime.now(), "updated_at": datetime.now()})
|
||||
"connected_at": _beijing_now(), "created_at": _beijing_now(), "updated_at": _beijing_now()})
|
||||
else:
|
||||
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET
|
||||
bot_token=:bot_token, ilink_bot_id=:ilink_bot_id, baseurl=:baseurl,
|
||||
status='connected', connected_at=:connected_at, updated_at=:updated_at WHERE id=:id"""),
|
||||
{"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl,
|
||||
"connected_at": datetime.now(), "updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
|
||||
"connected_at": _beijing_now(), "updated_at": _beijing_now(), "id": _row_to_dict(row)["id"]})
|
||||
row = _wechat_row(db)
|
||||
aid = _row_to_dict(row)["id"] if row else 1
|
||||
_start_wechat_receiver(aid)
|
||||
@@ -821,7 +829,7 @@ async def wechat_disconnect(user=Depends(get_current_user)):
|
||||
row = _wechat_row(db)
|
||||
if row:
|
||||
_execute_raw(db, text("UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', updated_at=:updated_at WHERE id=:id"),
|
||||
{"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
|
||||
{"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"]})
|
||||
return {"success": True, "message": "已断开个人微信连接"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -18,6 +18,7 @@ import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import quote as _quote
|
||||
@@ -39,8 +40,13 @@ TZ_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _beijing_now() -> datetime:
|
||||
"""返回北京时间的 naive datetime(去掉 tzinfo 以便直接存入 MySQL DATETIME)。"""
|
||||
return datetime.now(TZ_BEIJING).replace(tzinfo=None)
|
||||
"""返回带时区的北京时间(aware datetime)。
|
||||
|
||||
#977 关键修复:平台已全量迁 PG(时间列均为 timestamp with time zone),
|
||||
必须返回 aware datetime。此前 .replace(tzinfo=None) 返回 naive,PG 按 UTC
|
||||
误解导致错位 8 小时(如北京时间 13:53 存成 13:53 UTC → 前端显示 21:53)。
|
||||
"""
|
||||
return datetime.now(TZ_BEIJING)
|
||||
|
||||
# ============================================================
|
||||
# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID)
|
||||
@@ -54,6 +60,9 @@ WECHAT_TEXT_MAX_LEN = 1500
|
||||
# 每个已连接账号一个后台接收线程,key = 账号表主键 id
|
||||
_wechat_receiver_threads: dict[int, threading.Thread] = {}
|
||||
|
||||
# typing_ticket 缓存:{ilink_user_id: (ticket, 写入时间戳)},24h 有效
|
||||
_typing_ticket_cache: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 通用 DB 工具
|
||||
@@ -127,7 +136,11 @@ def _recent_history(user_id: str, limit: int = 6) -> str:
|
||||
for r in reversed(rows):
|
||||
dd = _row_to_dict(r)
|
||||
role = "用户" if dd["role"] == "user" else "七虎AI"
|
||||
pairs.append(f"{role}:{dd['content']}")
|
||||
content = dd.get("content") or ""
|
||||
# #973:每条历史最多 500 字符,防止超长图片 URL/完整清单撑爆多轮 prompt
|
||||
if len(content) > 500:
|
||||
content = content[:500] + "…"
|
||||
pairs.append(f"{role}:{content}")
|
||||
return "\n".join(pairs)
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -205,11 +218,14 @@ def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_toke
|
||||
ret=-14=会话过期需重扫;ret=-2=token 失效。"""
|
||||
payload = {
|
||||
"msg": {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to_user_id,
|
||||
"client_id": uuid.uuid4().hex,
|
||||
"message_type": 2,
|
||||
"message_state": 2,
|
||||
"context_token": context_token,
|
||||
"item_list": item_list,
|
||||
"run_id": uuid.uuid4().hex,
|
||||
}
|
||||
}
|
||||
last_body = {}
|
||||
@@ -307,6 +323,70 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
|
||||
return all_ok, last_ret, {}
|
||||
|
||||
|
||||
def _wechat_get_typing_ticket(baseurl: str, bot_token: str, ilink_user_id: str, context_token: str) -> str:
|
||||
"""getconfig 获取 typing_ticket(按 ilink_user_id 缓存 24h)。
|
||||
|
||||
#974 修复:ilink_user_id 必须传【用户 ID】(from_user_id),不是 bot ID;
|
||||
请求体必须带 base_info;响应必须 ret==0 才算成功。
|
||||
"""
|
||||
global _typing_ticket_cache
|
||||
_now = time.time()
|
||||
_hit = _typing_ticket_cache.get(ilink_user_id)
|
||||
if _hit and (_now - _hit[1]) < 86400:
|
||||
return _hit[0]
|
||||
try:
|
||||
resp = requests.post(
|
||||
baseurl.rstrip("/") + "/ilink/bot/getconfig",
|
||||
headers=_wechat_headers(bot_token),
|
||||
json={
|
||||
"ilink_user_id": ilink_user_id,
|
||||
"context_token": context_token,
|
||||
"base_info": {"channel_version": "1.0.3"},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
body = resp.json() if resp.content else {}
|
||||
# ret==0 才算成功;-14 会话过期 / -4 RPC 失败均无 ticket
|
||||
ret = body.get("ret")
|
||||
errcode = body.get("errcode")
|
||||
if ret == 0 or (ret is None and errcode is None):
|
||||
ticket = body.get("typing_ticket", "") if isinstance(body, dict) else ""
|
||||
if ticket:
|
||||
_typing_ticket_cache[ilink_user_id] = (ticket, _now)
|
||||
return ticket
|
||||
# 会话过期:记录,供上层标记 expired
|
||||
if errcode == -14 or ret == -14:
|
||||
try:
|
||||
with open("/tmp/wechat_session_expired.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " GETCONFIG_SESSION_EXPIRED user=%s\n" % ilink_user_id)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _wechat_send_typing(baseurl: str, bot_token: str, ilink_user_id: str, typing_ticket: str, status: int = 1) -> bool:
|
||||
"""发送/取消"正在输入"状态。status=1 typing,2 cancel。失败静默(不阻塞主流程)。"""
|
||||
if not ilink_user_id or not typing_ticket:
|
||||
return False
|
||||
try:
|
||||
resp = requests.post(
|
||||
baseurl.rstrip("/") + "/ilink/bot/sendtyping",
|
||||
headers=_wechat_headers(bot_token),
|
||||
json={
|
||||
"ilink_user_id": ilink_user_id,
|
||||
"typing_ticket": typing_ticket,
|
||||
"status": status,
|
||||
"base_info": {"channel_version": "1.0.3"},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
return resp.ok
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _platform_relay_url(raw_url: str) -> str:
|
||||
"""把 AGNES 等境外媒体 URL 改写为平台中转地址,使微信/iLink 可内联渲染。
|
||||
|
||||
@@ -319,21 +399,44 @@ def _platform_relay_url(raw_url: str) -> str:
|
||||
|
||||
def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
|
||||
media_type: str, url: str):
|
||||
"""发送媒体(图片/视频)。
|
||||
"""发送媒体(图片/视频)。#969 iLink CDN 内联发送
|
||||
|
||||
问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时。
|
||||
解决:改写为平台中转地址(经香港代理可达 AGNES)。
|
||||
|
||||
2026-07-31 兼容修复:iLink 对 type=2/5 的媒体 item 返回 ret=-2 invalid arguments,
|
||||
且旧代码把 ret=-2 误判为 token 失效,导致连接假性断开。先以纯文本链接形式发送,
|
||||
确保用户可点击访问且不会破坏会话;待拿到 iLink 媒体协议文档后再恢复内联 item。
|
||||
优先走 iLink 官方 CDN 加密上传链路,实现图片/视频真正内联显示;
|
||||
失败时降级为纯文本链接(点击访问),确保会话不中断。
|
||||
"""
|
||||
relay_url = _platform_relay_url(url)
|
||||
# 尝试内联发送(iLink CDN 加密上传)
|
||||
try:
|
||||
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_URL_LEN=%s type=%s\n" % (len(relay_url), media_type))
|
||||
except Exception:
|
||||
pass
|
||||
from app.api.im_ilink_media import (
|
||||
MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO,
|
||||
download_media_bytes, upload_media_to_wechat_cdn,
|
||||
build_image_item, build_video_item,
|
||||
)
|
||||
media_bytes = download_media_bytes(url)
|
||||
mt = MEDIA_TYPE_IMAGE if media_type == "image" else MEDIA_TYPE_VIDEO
|
||||
uploaded = upload_media_to_wechat_cdn(baseurl, bot_token, to_user_id, media_bytes, mt)
|
||||
item = build_image_item(uploaded) if media_type == "image" else build_video_item(uploaded)
|
||||
ok, ret, _ = _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [item])
|
||||
if ok:
|
||||
try:
|
||||
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_OK type=%s size=%d\n"
|
||||
% (media_type, len(media_bytes)))
|
||||
except Exception:
|
||||
pass
|
||||
return True, ret, {}
|
||||
try:
|
||||
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_FAIL ret=%s → fallback text\n" % (ret,))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
try:
|
||||
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_EXC %s → fallback text\n" % (_e,))
|
||||
except Exception:
|
||||
pass
|
||||
# 降级:纯文本链接
|
||||
if media_type == "image":
|
||||
caption = "🖼️ 图片已生成,点击查看:" + relay_url
|
||||
else:
|
||||
@@ -344,8 +447,14 @@ def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_to
|
||||
def _wechat_extract_text(msg: dict) -> str:
|
||||
parts = []
|
||||
for it in (msg.get("item_list") or []):
|
||||
if it.get("type") == 1 and it.get("text_item", {}).get("text"):
|
||||
_t = it.get("type")
|
||||
if _t == 1 and it.get("text_item", {}).get("text"):
|
||||
parts.append(it["text_item"]["text"])
|
||||
elif _t == 3:
|
||||
# 语音消息:微信已自带 ASR 转文字(voice_item.text),直接复用
|
||||
voice = it.get("voice_item") or {}
|
||||
if voice.get("text"):
|
||||
parts.append("[语音] " + str(voice["text"]))
|
||||
if not parts:
|
||||
for k in ("text", "content", "message", "msg"):
|
||||
v = msg.get(k)
|
||||
@@ -354,6 +463,42 @@ def _wechat_extract_text(msg: dict) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_and_save_inbound_media(msg: dict) -> str:
|
||||
"""检测入站媒体消息(图片/语音/文件/视频),下载解密保存,返回给 LLM 的提示文本。
|
||||
|
||||
无媒体或解密失败返回空字符串。图片已保存到本地,供后续 vision 模型接入。
|
||||
"""
|
||||
try:
|
||||
from app.api.im_ilink_media import extract_inbound_media
|
||||
import os as _os
|
||||
for it in (msg.get("item_list") or []):
|
||||
if it.get("type") in (2, 3, 4, 5):
|
||||
kind, data = extract_inbound_media(it)
|
||||
if kind and data:
|
||||
_dir = "/tmp/wechat_inbound_media"
|
||||
_os.makedirs(_dir, exist_ok=True)
|
||||
_ext = {"image": "jpg", "voice": "silk", "file": "bin", "video": "mp4"}.get(kind, "bin")
|
||||
_path = _os.path.join(_dir, "%d_%s.%s" % (int(time.time() * 1000), kind, _ext))
|
||||
with open(_path, "wb") as _f:
|
||||
_f.write(data)
|
||||
_label = {"image": "图片", "voice": "语音", "file": "文件", "video": "视频"}.get(kind, kind)
|
||||
try:
|
||||
with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " %s saved=%s size=%d\n"
|
||||
% (kind, _path, len(data)))
|
||||
except Exception:
|
||||
pass
|
||||
return "[用户发送了%s,已保存到 %s(%d 字节)]" % (_label, _path, len(data))
|
||||
return ""
|
||||
except Exception as _e:
|
||||
try:
|
||||
with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_EXTRACT_ERR %s\n" % (_e,))
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _wechat_sender_id(m: dict) -> str:
|
||||
"""从入站消息中提取发送者 id(兼容多套字段名)。"""
|
||||
for k in ("from_user_id", "from_user_name", "from_username", "user_id",
|
||||
@@ -411,7 +556,7 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
|
||||
), {"uid": user_id})
|
||||
if rows:
|
||||
return _row_to_dict(rows[0])["id"]
|
||||
cid = str(_uuid.uuid4())
|
||||
cid = str(uuid.uuid4())
|
||||
now = _beijing_now()
|
||||
_execute_raw(db, text(
|
||||
"INSERT INTO conversations "
|
||||
@@ -426,7 +571,7 @@ def _append_ai_message(db: Session, conv_id: str, role: str, content: str, creat
|
||||
"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,
|
||||
"id": str(uuid.uuid4()), "cid": conv_id, "role": role,
|
||||
"content": content, "c": created or _beijing_now(),
|
||||
})
|
||||
|
||||
@@ -457,7 +602,7 @@ def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str):
|
||||
# 后台接收线程(每用户独立)
|
||||
# ============================================================
|
||||
def _mark_wechat_expired(account_id: int):
|
||||
"""发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。"""
|
||||
"""发送会话失效(ret=-14/-2):标记账号需重扫,清除失效 token,并记录告警日志。"""
|
||||
db = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
@@ -465,6 +610,14 @@ def _mark_wechat_expired(account_id: int):
|
||||
"UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, "
|
||||
"updated_at=:ts WHERE id=:aid"
|
||||
), {"aid": account_id, "ts": _beijing_now()})
|
||||
# Session Guard:记录账号信息,供前台提示用户重扫
|
||||
row = _execute_raw(db, text("SELECT user_id, username FROM im_bot_wechat_account WHERE id=:aid"), {"aid": account_id})
|
||||
_uid = row[0][0] if row else "?"
|
||||
try:
|
||||
with open("/tmp/wechat_session_expired.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " SESSION_EXPIRED account_id=%s user_id=%s\n" % (account_id, _uid))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
@@ -497,7 +650,10 @@ def _wechat_receiver_loop(account_id: int):
|
||||
continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理
|
||||
content = _wechat_extract_text(m)
|
||||
if not content:
|
||||
continue
|
||||
# 入站媒体(图片/语音/文件/视频):下载解密保存,供 LLM 感知
|
||||
content = _extract_and_save_inbound_media(m)
|
||||
if not content:
|
||||
continue
|
||||
sender = _wechat_sender_id(m)
|
||||
ctx = _wechat_context_token(m)
|
||||
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
|
||||
@@ -507,6 +663,13 @@ def _wechat_receiver_loop(account_id: int):
|
||||
+ " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# 发送"正在输入"状态(#974:ilink_user_id 用用户 ID = sender,非 bot ID)
|
||||
try:
|
||||
_ticket = _wechat_get_typing_ticket(baseurl, bot_token, sender, ctx)
|
||||
if sender and _ticket:
|
||||
_wechat_send_typing(baseurl, bot_token, sender, _ticket, 1)
|
||||
except Exception:
|
||||
pass
|
||||
# 先生成回复(此时历史尚未包含本条,避免重复)
|
||||
import logging as _lrx, inspect as _insp
|
||||
_lrx.warning(f'[receiver] calling _qihua_reply({repr(content)}, {repr(owner_uid)})')
|
||||
@@ -524,11 +687,8 @@ def _wechat_receiver_loop(account_id: int):
|
||||
ok, ret, _ = _wechat_send_media(baseurl, bot_token, sender, ctx, media_type, media_url)
|
||||
# 对话历史里存可渲染的媒体标签(经平台中转,AI 聊天 WeXin Bot 窗与历史页均可内联显示)
|
||||
relay_url = _platform_relay_url(media_url)
|
||||
reply_for_log = (
|
||||
f'<img src="{relay_url}" alt="生成图片"/>'
|
||||
if media_type == "image"
|
||||
else f'<video src="{relay_url}" controls></video>'
|
||||
)
|
||||
# #973:对话历史只存简短占位符,不存超长 relay URL(含 AWS token,会撑爆多轮 prompt)
|
||||
reply_for_log = "[图片]" if media_type == "image" else "[视频]"
|
||||
else:
|
||||
reply_text = reply if isinstance(reply, str) else str(reply)
|
||||
ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply_text)
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
iLink CDN 媒体加解密 —— 微信官方 iLink(ClawBot) 协议媒体内联发送核心模块
|
||||
im_ilink_media.py
|
||||
|
||||
参考微信官方 OpenClaw 插件 (@tencent-weixin/openclaw-weixin) 源码实现:
|
||||
- cdn/aes-ecb.ts: AES-128-ECB + PKCS7 加解密
|
||||
- cdn/upload.ts: 六步上传流程
|
||||
- messaging/send.ts: image_item / video_item 构造
|
||||
|
||||
严格照抄官方行为,保证字段名与编码方式与微信服务器一致:
|
||||
- aeskey = 随机 16 字节
|
||||
- getUploadUrl 传 aeskey.hex()(32 字符 hex)
|
||||
- 加密用 16 字节原始 key
|
||||
- sendmessage 的 media.aes_key = base64(hex 字符串的 UTF-8 字节)
|
||||
|
||||
媒体类型映射: IMAGE=1, VIDEO=2, FILE=3, VOICE=4
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives import padding
|
||||
|
||||
# 微信 CDN 域名(图片/语音/视频媒体文件走这里)
|
||||
WECHAT_CDN_BASE = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
# 媒体类型枚举(与 iLink UploadMediaType 一致)
|
||||
MEDIA_TYPE_IMAGE = 1
|
||||
MEDIA_TYPE_VIDEO = 2
|
||||
MEDIA_TYPE_FILE = 3
|
||||
MEDIA_TYPE_VOICE = 4
|
||||
|
||||
|
||||
def encrypt_aes_ecb(plaintext: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB + PKCS7 加密(微信指定,非标准 CBC)。"""
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded = padder.update(plaintext) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
enc = cipher.encryptor()
|
||||
return enc.update(padded) + enc.finalize()
|
||||
|
||||
|
||||
def decrypt_aes_ecb(ciphertext: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB + PKCS7 解密。"""
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
dec = cipher.decryptor()
|
||||
padded = dec.update(ciphertext) + dec.finalize()
|
||||
unpadder = padding.PKCS7(128).unpadder()
|
||||
return unpadder.update(padded) + unpadder.finalize()
|
||||
|
||||
|
||||
def aes_ecb_padded_size(plaintext_size: int) -> int:
|
||||
"""AES-128-ECB PKCS7 填充后的密文大小。"""
|
||||
return math.ceil((plaintext_size + 1) / 16) * 16
|
||||
|
||||
|
||||
def _wechat_headers(bot_token: str) -> dict:
|
||||
"""iLink 协议请求头(复制 im_bot_user 逻辑,避免循环依赖)。"""
|
||||
uin = base64.b64encode(str(random_int()).encode("utf-8")).decode("utf-8")
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {bot_token}",
|
||||
"AuthorizationType": "ilink_bot_token",
|
||||
"X-WECHAT-UIN": uin,
|
||||
}
|
||||
|
||||
|
||||
def random_int():
|
||||
import random
|
||||
return random.randint(0, 2 ** 32 - 1)
|
||||
|
||||
|
||||
def download_media_bytes(url: str) -> bytes:
|
||||
"""下载 AGNES/供应商媒体字节(走 HK 代理,绕过劫持)。
|
||||
|
||||
复用 chat_utils._proxy_for_url 的代理选择逻辑;对境外媒体走 HK 代理。
|
||||
"""
|
||||
from app.api.chat_utils import _proxy_for_url
|
||||
|
||||
proxy = _proxy_for_url(url)
|
||||
proxies = None
|
||||
if proxy:
|
||||
proxies = {"http": proxy, "https": proxy}
|
||||
# trust_env=False 语义等价:requests 不读环境变量代理
|
||||
# 显式传 proxies=None 且环境变量存在时 requests 会用环境变量,故需清空
|
||||
import os as _os
|
||||
env_bak = {}
|
||||
for _k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
if _k in _os.environ:
|
||||
env_bak[_k] = _os.environ.pop(_k)
|
||||
try:
|
||||
resp = requests.get(url, proxies=proxies, timeout=180, allow_redirects=True)
|
||||
finally:
|
||||
_os.environ.update(env_bak)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"media download failed: {resp.status_code} url={url[:120]}")
|
||||
return resp.content
|
||||
|
||||
|
||||
def upload_media_to_wechat_cdn(
|
||||
baseurl: str,
|
||||
bot_token: str,
|
||||
to_user_id: str,
|
||||
media_bytes: bytes,
|
||||
media_type: int,
|
||||
) -> dict:
|
||||
"""六步上传流程:读文件→hash→gen aeskey→getUploadUrl→加密上传CDN→返回引用。
|
||||
|
||||
返回 uploaded dict: { filekey, download_param, aeskey_hex, filesize }
|
||||
"""
|
||||
rawsize = len(media_bytes)
|
||||
rawfilemd5 = hashlib.md5(media_bytes).hexdigest()
|
||||
filesize = aes_ecb_padded_size(rawsize)
|
||||
filekey = os.urandom(16).hex() # 32 字符 hex
|
||||
aeskey = os.urandom(16) # 16 字节原始 key
|
||||
aeskey_hex = aeskey.hex() # 32 字符 hex
|
||||
|
||||
# 1. getUploadUrl
|
||||
resp = requests.post(
|
||||
baseurl.rstrip("/") + "/ilink/bot/getuploadurl",
|
||||
headers=_wechat_headers(bot_token),
|
||||
json={
|
||||
"filekey": filekey,
|
||||
"media_type": media_type,
|
||||
"to_user_id": to_user_id,
|
||||
"rawsize": rawsize,
|
||||
"rawfilemd5": rawfilemd5,
|
||||
"filesize": filesize,
|
||||
"no_need_thumb": True,
|
||||
"aeskey": aeskey_hex,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"getuploadurl HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
body = resp.json() if resp.content else {}
|
||||
upload_param = body.get("upload_param")
|
||||
upload_full_url = (body.get("upload_full_url") or "").strip()
|
||||
|
||||
if not upload_param and not upload_full_url:
|
||||
raise RuntimeError(f"getuploadurl returned no upload URL: {str(body)[:200]}")
|
||||
|
||||
# 2. AES-128-ECB 加密
|
||||
ciphertext = encrypt_aes_ecb(media_bytes, aeskey)
|
||||
|
||||
# 3. 上传 CDN
|
||||
if upload_full_url:
|
||||
cdn_url = upload_full_url
|
||||
else:
|
||||
cdn_url = (
|
||||
f"{WECHAT_CDN_BASE}/upload"
|
||||
f"?encrypted_query_param={quote(upload_param, safe='')}"
|
||||
f"&filekey={quote(filekey, safe='')}"
|
||||
)
|
||||
up_resp = requests.post(
|
||||
cdn_url,
|
||||
data=ciphertext,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
timeout=120,
|
||||
)
|
||||
if up_resp.status_code not in (200, 201):
|
||||
raise RuntimeError(f"CDN upload failed HTTP {up_resp.status_code}: {up_resp.text[:200]}")
|
||||
download_param = up_resp.headers.get("x-encrypted-param")
|
||||
if not download_param:
|
||||
raise RuntimeError("CDN upload response missing x-encrypted-param header")
|
||||
|
||||
return {
|
||||
"filekey": filekey,
|
||||
"download_param": download_param,
|
||||
"aeskey_hex": aeskey_hex,
|
||||
"filesize": filesize,
|
||||
}
|
||||
|
||||
|
||||
def _aes_key_b64(aeskey_hex: str) -> str:
|
||||
"""media.aes_key 编码:base64(hex 字符串的 UTF-8 字节)。
|
||||
|
||||
严格对齐 openclaw: Buffer.from(hexString).toString('base64')
|
||||
"""
|
||||
return base64.b64encode(aeskey_hex.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def build_image_item(uploaded: dict) -> dict:
|
||||
"""构造图片 MessageItem(type=2)。"""
|
||||
return {
|
||||
"type": 2,
|
||||
"image_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": uploaded["download_param"],
|
||||
"aes_key": _aes_key_b64(uploaded["aeskey_hex"]),
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"mid_size": uploaded["filesize"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_video_item(uploaded: dict) -> dict:
|
||||
"""构造视频 MessageItem(type=5)。"""
|
||||
return {
|
||||
"type": 5,
|
||||
"video_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": uploaded["download_param"],
|
||||
"aes_key": _aes_key_b64(uploaded["aeskey_hex"]),
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"video_size": uploaded["filesize"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 接收侧媒体下载解密(P2)
|
||||
# 参考 openclaw cdn/pic-decrypt.ts + media/media-download.ts
|
||||
# ============================================================
|
||||
|
||||
def parse_aes_key(aes_key_base64: str) -> bytes:
|
||||
"""解析 CDNMedia.aes_key 为原始 16 字节 AES key。
|
||||
|
||||
两种真实编码(openclaw parseAesKey):
|
||||
- base64(raw 16 bytes) → 图片 (media.aes_key)
|
||||
- base64(hex 32 chars 字符串) → 文件/语音/视频
|
||||
第二种需 base64 解码后得到 32 个 ASCII hex 字符,再 hex 解码成 16 字节。
|
||||
"""
|
||||
decoded = base64.b64decode(aes_key_base64)
|
||||
if len(decoded) == 16:
|
||||
return decoded
|
||||
if len(decoded) == 32 and re.match(r"^[0-9a-fA-F]{32}$", decoded.decode("ascii", "ignore")):
|
||||
return bytes.fromhex(decoded.decode("ascii"))
|
||||
raise ValueError(
|
||||
f"aes_key 解码后应为 16 字节或 32 字符 hex,实际 {len(decoded)} 字节"
|
||||
)
|
||||
|
||||
|
||||
def download_and_decrypt_media(
|
||||
encrypted_query_param: str,
|
||||
aes_key_base64: str,
|
||||
cdn_base_url: str = WECHAT_CDN_BASE,
|
||||
full_url: str = None,
|
||||
) -> bytes:
|
||||
"""下载并 AES-128-ECB 解密 CDN 媒体文件,返回明文字节。"""
|
||||
key = parse_aes_key(aes_key_base64)
|
||||
if full_url:
|
||||
url = full_url
|
||||
else:
|
||||
url = (
|
||||
f"{cdn_base_url}/download"
|
||||
f"?encrypted_query_param={quote(encrypted_query_param, safe='')}"
|
||||
)
|
||||
resp = requests.get(url, timeout=60)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"CDN download failed HTTP {resp.status_code}: {resp.text[:150]}")
|
||||
encrypted = resp.content
|
||||
return decrypt_aes_ecb(encrypted, key)
|
||||
|
||||
|
||||
def extract_inbound_media(item: dict) -> tuple:
|
||||
"""从入站 MessageItem 提取媒体并下载解密。
|
||||
|
||||
返回 (media_kind, media_bytes),不支持的类型或失败返回 (None, None)。
|
||||
media_kind ∈ {"image", "voice", "file", "video"}
|
||||
"""
|
||||
try:
|
||||
t = item.get("type")
|
||||
if t == 2: # 图片
|
||||
img = item.get("image_item") or {}
|
||||
media = img.get("media") or {}
|
||||
eqp = media.get("encrypt_query_param") or ""
|
||||
full = media.get("full_url")
|
||||
if not eqp and not full:
|
||||
return None, None
|
||||
# 图片 key 优先用 image_item.aeskey(hex string)转 base64
|
||||
if img.get("aeskey"):
|
||||
aes_key_b64 = base64.b64encode(bytes.fromhex(img["aeskey"])).decode()
|
||||
else:
|
||||
aes_key_b64 = media.get("aes_key") or ""
|
||||
if aes_key_b64:
|
||||
return "image", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
|
||||
return "image", _download_plain(eqp, full)
|
||||
elif t == 3: # 语音
|
||||
voice = item.get("voice_item") or {}
|
||||
media = voice.get("media") or {}
|
||||
eqp = media.get("encrypt_query_param") or ""
|
||||
full = media.get("full_url")
|
||||
aes_key_b64 = media.get("aes_key") or ""
|
||||
if not aes_key_b64:
|
||||
return None, None
|
||||
return "voice", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
|
||||
elif t == 4: # 文件
|
||||
fitem = item.get("file_item") or {}
|
||||
media = fitem.get("media") or {}
|
||||
eqp = media.get("encrypt_query_param") or ""
|
||||
full = media.get("full_url")
|
||||
aes_key_b64 = media.get("aes_key") or ""
|
||||
if not aes_key_b64:
|
||||
return None, None
|
||||
return "file", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
|
||||
elif t == 5: # 视频
|
||||
vitem = item.get("video_item") or {}
|
||||
media = vitem.get("media") or {}
|
||||
eqp = media.get("encrypt_query_param") or ""
|
||||
full = media.get("full_url")
|
||||
aes_key_b64 = media.get("aes_key") or ""
|
||||
if not aes_key_b64:
|
||||
return None, None
|
||||
return "video", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
|
||||
except Exception:
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _download_plain(encrypted_query_param: str, full_url: str = None) -> bytes:
|
||||
"""下载未加密的 CDN 媒体(无 aes_key 时)。"""
|
||||
if full_url:
|
||||
url = full_url
|
||||
else:
|
||||
url = (
|
||||
f"{WECHAT_CDN_BASE}/download"
|
||||
f"?encrypted_query_param={quote(encrypted_query_param, safe='')}"
|
||||
)
|
||||
resp = requests.get(url, timeout=60)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"CDN plain download failed HTTP {resp.status_code}")
|
||||
return resp.content
|
||||
@@ -277,10 +277,11 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
f"mcp_tools={len(mcp_function_definitions)}"
|
||||
)
|
||||
|
||||
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
|
||||
# 平台知识库注入(#973:IM 侧精简——只注入静态文档,不注入 104 动态清单,
|
||||
# 避免 prompt 过长(6892字符)导致推理模型响应慢(63s)且间歇性返回空 content)
|
||||
kb_context = ""
|
||||
try:
|
||||
pk = get_platform_knowledge(db=db)
|
||||
pk = get_platform_knowledge(db=None)
|
||||
if pk:
|
||||
kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
|
||||
except Exception:
|
||||
@@ -420,6 +421,9 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
if round_num >= max_rounds - 1:
|
||||
break
|
||||
continue
|
||||
# #973:空 content 且未到最后一轮 → 重试(推理模型间歇性返回空)
|
||||
if not ai_content and round_num < max_rounds - 1:
|
||||
continue
|
||||
break
|
||||
|
||||
# 检查本轮调用是否成功
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
# 微信 Bot iLink 协议重构方案
|
||||
|
||||
> **版本**: v1.0 | **日期**: 2026-08-15 | **作者**: 七虎AI 技术团队
|
||||
> **背景**: 参考微信官方 iLink (ClawBot) 协议完整解析,对平台现有个人微信 Bot 实现做差距分析并重构
|
||||
> **关联**: `im_bot_user.py`(前台用户级)、`admin/im_bot_admin.py`(后台平台级)、`im_llm.py`(EngineCore 薄壳)
|
||||
|
||||
---
|
||||
|
||||
## 一、协议背景:微信官方 iLink (ClawBot)
|
||||
|
||||
### 1.1 定位
|
||||
|
||||
iLink(智联)是腾讯微信 2026 年官方开放的个人微信机器人通信协议,对外产品名 **ClawBot**。这是**唯一合规**的个人微信 Bot 通道,有《微信 ClawBot 功能使用条款》法律背书。
|
||||
|
||||
| 维度 | 逆向 iPad/PC Hook(旧灰产) | iLink Bot API(官方合规) |
|
||||
|------|---------------------------|--------------------------|
|
||||
| 合规性 | 违反微信用户协议 | 官方开放,签署使用条款 |
|
||||
| 封号风险 | 极高,大号易永久封禁 | 正常使用无封号风险 |
|
||||
| 协议稳定性 | 微信升级即失效 | 服务端 API,版本可控 |
|
||||
| 登录方式 | 模拟设备/劫持会话 | 微信扫码授权,临时 token |
|
||||
| 通信方式 | 模拟心跳,触发风控 | 长轮询,无需公网 IP |
|
||||
|
||||
### 1.2 核心架构(三层)
|
||||
|
||||
```
|
||||
上层(逻辑): 业务代码 —— 消息收发、AI 应答、工具调用
|
||||
中层(文件): CDN 媒体加解密 —— AES-128-ECB + PKCS7
|
||||
底层(通道): iLink 长轮询 —— 消息通道(ilinkai.weixin.qq.com)
|
||||
```
|
||||
|
||||
两个核心域名:
|
||||
| 域名 | 用途 |
|
||||
|------|------|
|
||||
| `https://ilinkai.weixin.qq.com` | 所有 API 请求 |
|
||||
| `https://novac2c.cdn.weixin.qq.com/c2c` | 图片/语音/视频媒体文件 |
|
||||
|
||||
### 1.3 七个 API 端点
|
||||
|
||||
| 端点 | 方法 | 功能 | 关键参数 |
|
||||
|------|------|------|---------|
|
||||
| `/ilink/bot/get_bot_qrcode` | GET | 获取登录二维码 | `?bot_type=3` |
|
||||
| `/ilink/bot/get_qrcode_status` | GET | 轮询扫码状态 | `?qrcode=xxx` |
|
||||
| `/ilink/bot/getupdates` | POST | 长轮询收消息(35s hold) | `{ get_updates_buf }` |
|
||||
| `/ilink/bot/sendmessage` | POST | 发送消息 | `{ msg: { context_token, item_list } }` |
|
||||
| `/ilink/bot/getuploadurl` | POST | 获取 CDN 上传地址 | `{ filekey, md5, len }` |
|
||||
| `/ilink/bot/getconfig` | POST | 获取配置 | `{ base_info }` |
|
||||
| `/ilink/bot/sendtyping` | POST | 发送"正在输入"状态 | `{ to_user_id, typing_ticket }` |
|
||||
|
||||
### 1.4 鉴权机制
|
||||
|
||||
```
|
||||
Authorization: Bearer {bot_token} # 扫码登录获取
|
||||
AuthorizationType: ilink_bot_token # 固定值
|
||||
X-WECHAT-UIN: {随机uint32 → base64} # 每次请求换新,防重放攻击
|
||||
```
|
||||
|
||||
### 1.5 消息机制
|
||||
|
||||
- **长轮询**:`get_updates_buf` 是游标/位置书签,每次请求必须用返回的新值替换旧值,否则重复收消息死循环
|
||||
- **context_token**:每条消息的"身份证",回复必须原样回带,否则不关联到正确对话窗口
|
||||
- **不能主动推送**:必须由用户先发消息触发
|
||||
- **消息类型**:`type 1`文本 / `2`图片 / `3`语音 / `4`文件 / `5`视频
|
||||
- **语音自带 ASR**:VoiceItem 含 `text` 字段,微信已做好语音转文字
|
||||
|
||||
---
|
||||
|
||||
## 二、现状分析
|
||||
|
||||
### 2.1 已实现能力(im_bot_user.py)
|
||||
|
||||
| 能力 | 实现 | 状态 |
|
||||
|------|------|------|
|
||||
| 扫码登录 | `get_bot_qrcode` + `get_qrcode_status` 轮询 | ✅ 完整 |
|
||||
| 长轮询收消息 | `getupdates` + `get_updates_buf` 游标推进 | ✅ 完整 |
|
||||
| 发消息 | `sendmessage` + `context_token` 回带 | ✅ 完整 |
|
||||
| 鉴权 | `ilink_bot_token` + `X-WECHAT-UIN` 随机防重放 | ✅ 完整 |
|
||||
| 文本分段 | `WECHAT_TEXT_MAX_LEN=1500` 超长分段 | ✅ 完整 |
|
||||
| 会话过期检测 | `ret=-14/-2` 区分过期/参数错误 | ✅ 完整 |
|
||||
| 用户级隔离 | 前台用户各自绑定,`im_bot_wechat_account` 按 user_id 隔离 | ✅ 完整 |
|
||||
| token 持久化 | `bot_token`/`ilink_bot_id` 存 PG | ✅ 完整 |
|
||||
| 对话历史 | `_log_chat` + 镜像到 AI 聊天窗 | ✅ 完整 |
|
||||
| 异常日志 | 接收器异常写 `/tmp/wechat_receiver_err.log` | ✅ 完整 |
|
||||
| 双 Claw 单引擎 | `im_llm.py` 薄壳复用 EngineCore | ✅ 完整 |
|
||||
|
||||
### 2.2 差距分析(核心问题)
|
||||
|
||||
| # | 能力 | 当前状态 | 影响 | 优先级 |
|
||||
|---|------|---------|------|--------|
|
||||
| 1 | **媒体内联发送** | ❌ 降级为纯文本链接(`🖼️ 图片已生成,点击查看:URL`) | 图片/视频不能内联显示,用户体验差 | 🔴 P0 |
|
||||
| 2 | **sendtyping** | ❌ 未实现 | AI 回复等待期无"正在输入"提示 | 🟡 P1 |
|
||||
| 3 | **语音消息** | ❌ 未提取 VoiceItem ASR text | 语音消息无法应答 | 🟡 P1 |
|
||||
| 4 | **Session Guard 增强** | ⚠️ `-14` 仅标记 expired,无主动通知 | 会话过期用户无感知,需手动发现重扫 | 🟡 P1 |
|
||||
| 5 | **CDN 下载解密** | ❌ 未实现(收图片/语音/文件无法解密) | 无法处理用户发来的图片/文件 | 🟢 P2 |
|
||||
|
||||
### 2.3 核心痛点详述:媒体内联发送
|
||||
|
||||
**当前实现**(`_wechat_send_media`):
|
||||
```python
|
||||
def _wechat_send_media(...):
|
||||
# iLink 对 type=2/5 媒体 item 返回 ret=-2 invalid arguments
|
||||
# 降级为纯文本链接
|
||||
caption = "🖼️ 图片已生成,点击查看:" + relay_url
|
||||
return _wechat_send_message(...) # 发文本
|
||||
```
|
||||
|
||||
**根本原因**:iLink 媒体消息不是直接传 URL,而是需要走 **CDN 上传加密链路**:
|
||||
1. 下载 AGNES 生成的图片字节
|
||||
2. 用 `getuploadurl` 获取微信 CDN 上传地址
|
||||
3. **AES-128-ECB 加密**图片字节
|
||||
4. 上传到 `novac2c.cdn.weixin.qq.com`
|
||||
5. 发消息时带 `download_param` + `aes_key`(type=2 图片 / type=5 视频)
|
||||
|
||||
当前代码缺少整个 CDN 加密链路,所以只能降级为文本链接。
|
||||
|
||||
---
|
||||
|
||||
## 三、重构方案
|
||||
|
||||
### 3.1 总体设计
|
||||
|
||||
```
|
||||
用户微信 ──(文本/图片/语音)──> iLink getupdates
|
||||
│
|
||||
▼
|
||||
┌── im_bot_user.py 接收循环 ──┐
|
||||
│ 提取 text / VoiceItem ASR │
|
||||
│ (P1) 语音转文字 │
|
||||
└──────────┬─────────────────┘
|
||||
▼
|
||||
EngineCore._send_message_core
|
||||
(双 Claw 单引擎,复用 LLM/MCP)
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼
|
||||
文本回复 媒体回复(__media__)
|
||||
│ │
|
||||
│ ┌──────────────┴──────────────┐
|
||||
│ │ (P0) 媒体内联发送链路 │
|
||||
│ │ 1. 下载 AGNES 图片字节 │
|
||||
│ │ 2. getuploadurl 拿地址 │
|
||||
│ │ 3. AES-128-ECB 加密 │
|
||||
│ │ 4. 上传微信 CDN │
|
||||
│ │ 5. sendmessage type=2/5 │
|
||||
│ └──────────────┬──────────────┘
|
||||
▼ ▼
|
||||
sendmessage (文本) sendmessage (媒体 item)
|
||||
+ (P1) sendtyping 前置
|
||||
```
|
||||
|
||||
### 3.2 分阶段实施
|
||||
|
||||
#### Phase 1(P0):媒体内联发送 —— 核心
|
||||
|
||||
**目标**:图片/视频真正内联显示在微信,替代当前文本链接降级。
|
||||
|
||||
**新增模块** `app/api/im_ilink_media.py`:
|
||||
```python
|
||||
"""
|
||||
iLink CDN 媒体加解密 —— 参考微信官方 iLink 协议
|
||||
核心:AES-128-ECB + PKCS7 加密上传,下载解密
|
||||
"""
|
||||
import hashlib, os
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives import padding
|
||||
|
||||
def _aes_ecb_encrypt(plaintext: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB + PKCS7 加密"""
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded = padder.update(plaintext) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
enc = cipher.encryptor()
|
||||
return enc.update(padded) + enc.finalize()
|
||||
|
||||
def _aes_ecb_decrypt(ciphertext: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB 解密"""
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
dec = cipher.decryptor()
|
||||
padded = dec.update(ciphertext) + dec.finalize()
|
||||
unpadder = padding.PKCS7(128).unpadder()
|
||||
return unpadder.update(padded) + unpadder.finalize()
|
||||
|
||||
def upload_media_to_wechat_cdn(bot_token: str, baseurl: str, media_bytes: bytes, filekey: str) -> dict:
|
||||
"""六步上传流程:
|
||||
1. 计算 MD5
|
||||
2. 生成随机 AES-128 key (16字节)
|
||||
3. POST /ilink/bot/getuploadurl → upload_param
|
||||
4. AES-128-ECB 加密文件
|
||||
5. POST novac2c.cdn.weixin.qq.com/c2c/upload
|
||||
6. 返回 download_param + aes_key
|
||||
"""
|
||||
md5 = hashlib.md5(media_bytes).hexdigest()
|
||||
aes_key = os.urandom(16)
|
||||
# ... getuploadurl 调用
|
||||
# ... 加密 + 上传
|
||||
return {"download_param": "...", "aes_key": aes_key.hex()}
|
||||
```
|
||||
|
||||
**修改 `_wechat_send_media`**:
|
||||
```python
|
||||
def _wechat_send_media(baseurl, bot_token, to_user_id, context_token, media_type, url):
|
||||
# 1. 下载 AGNES 图片字节(走 HK 代理,trust_env=False)
|
||||
media_bytes = _download_media(url)
|
||||
# 2. 上传微信 CDN + AES 加密
|
||||
cdn = upload_media_to_wechat_cdn(bot_token, baseurl, media_bytes, filekey)
|
||||
# 3. 发媒体消息(type=2 图片 / type=5 视频)
|
||||
item = {
|
||||
"type": 2 if media_type == "image" else 5,
|
||||
"image_item": { # 或 video_item
|
||||
"media": cdn["download_param"],
|
||||
"aes_key": cdn["aes_key"],
|
||||
}
|
||||
}
|
||||
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [item])
|
||||
```
|
||||
|
||||
**依赖**:`cryptography` 库(需加入 requirements.txt)
|
||||
|
||||
#### Phase 2(P1):sendtyping 正在输入
|
||||
|
||||
```python
|
||||
def _wechat_send_typing(baseurl, bot_token, to_user_id, typing_ticket):
|
||||
"""发消息前先发'正在输入'状态,改善等待体验"""
|
||||
return _post(baseurl + "/ilink/bot/sendtyping",
|
||||
{"to_user_id": to_user_id, "typing_ticket": typing_ticket})
|
||||
|
||||
# 接收循环中,调用 _qihua_reply 前:
|
||||
_wechat_send_typing(baseurl, bot_token, sender, typing_ticket)
|
||||
```
|
||||
- `typing_ticket` 从 `getconfig` 获取,按用户缓存 24h
|
||||
|
||||
#### Phase 3(P1):语音消息 ASR
|
||||
|
||||
```python
|
||||
def _wechat_extract_text(msg):
|
||||
"""扩展:支持语音消息自带 ASR 结果"""
|
||||
for it in (msg.get("item_list") or []):
|
||||
if it.get("type") == 1: # 文本
|
||||
...
|
||||
elif it.get("type") == 3: # 语音
|
||||
voice = it.get("voice_item", {})
|
||||
if voice.get("text"): # 微信已做好语音转文字
|
||||
parts.append(voice["text"])
|
||||
...
|
||||
```
|
||||
|
||||
#### Phase 4(P1):Session Guard 增强
|
||||
|
||||
```python
|
||||
def _mark_wechat_expired(account_id):
|
||||
"""增强:标记 expired 的同时,主动给用户发站内通知/邮件提醒重扫"""
|
||||
# 1. 标记 expired + 清 token(现有逻辑)
|
||||
# 2. 停掉接收线程
|
||||
# 3. 触发站内通知:'您的微信 Bot 会话已过期,请重新扫码绑定'
|
||||
# 4. 记录 audit log
|
||||
```
|
||||
|
||||
#### Phase 5(P2):接收侧媒体解密
|
||||
|
||||
```python
|
||||
def _download_wechat_media(msg):
|
||||
"""接收用户发来的图片/语音/文件:
|
||||
1. GET novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=xxx
|
||||
2. 用消息里的 aes_key 解密
|
||||
"""
|
||||
```
|
||||
|
||||
### 3.3 技术要点
|
||||
|
||||
| 要点 | 说明 |
|
||||
|------|------|
|
||||
| AES 模式 | AES-128-ECB(微信指定,非标准 CBC;一次性随机 key + HTTPS 保证安全) |
|
||||
| 密钥长度 | 16 字节(128-bit) |
|
||||
| 填充 | PKCS7 |
|
||||
| 密文大小 | `ceil((plaintext_size+1)/16)*16` |
|
||||
| 媒体域名 | `novac2c.cdn.weixin.qq.com/c2c/upload|download` |
|
||||
| 错误码 | `0`成功 / `-14`会话过期 / `-2`参数错误 |
|
||||
|
||||
### 3.4 风险与规避
|
||||
|
||||
| 风险 | 规避 |
|
||||
|------|------|
|
||||
| ECB 模式理论不安全 | 每文件一次性随机 key + HTTPS,风险可控 |
|
||||
| 媒体上传超时 | 大图/视频走异步,超时重试 + 降级文本链接兜底 |
|
||||
| `cryptography` 依赖 | 需在容器 venv 安装,纳入 requirements.txt |
|
||||
| iLink 媒体协议细节不明确 | 保留文本链接降级 fallback,逐步灰度 |
|
||||
|
||||
---
|
||||
|
||||
## 四、实施计划
|
||||
|
||||
| Phase | 内容 | 优先级 | 预估工作量 | 依赖 |
|
||||
|-------|------|--------|-----------|------|
|
||||
| P0 | 媒体内联发送(CDN AES 加密) | 🔴 高 | 2-3 天 | `cryptography` 库 |
|
||||
| P1 | sendtyping 正在输入 | 🟡 中 | 0.5 天 | 无 |
|
||||
| P1 | 语音 ASR | 🟡 中 | 0.5 天 | 无 |
|
||||
| P1 | Session Guard 增强 | 🟡 中 | 1 天 | 站内通知 |
|
||||
| P2 | 接收侧媒体解密 | 🟢 低 | 2 天 | P0 经验复用 |
|
||||
|
||||
---
|
||||
|
||||
## 五、验收标准
|
||||
|
||||
- [ ] P0:微信里生成的图片/视频**内联显示**,不再是文本链接
|
||||
- [ ] P1:AI 回复前微信显示"正在输入"
|
||||
- [ ] P1:发语音消息能正确应答(ASR 文本进入 LLM)
|
||||
- [ ] P1:会话过期时用户收到明确重扫通知
|
||||
- [ ] 双 Claw 单引擎不回归(`tests/test_dual_claw.py` 绿)
|
||||
- [ ] pytest + vitest 全绿
|
||||
|
||||
---
|
||||
|
||||
## 六、参考资料
|
||||
|
||||
- 微信官方 iLink (ClawBot) 协议(CSDN: 2401_88147261 / HiWangWenBing / bytenote)
|
||||
- OpenClaw 框架 `@tencent-weixin/openclaw-weixin` 增强:Session Guard / Config Cache / SILK 转码 / CDN 加解密
|
||||
- 平台现状代码:`app/api/im_bot_user.py`、`app/api/admin/im_bot_admin.py`、`app/api/im_llm.py`
|
||||
@@ -0,0 +1,126 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { LoginPage } from '@/pages/front/LoginPage';
|
||||
import { LandingPage } from '@/pages/front/LandingPage';
|
||||
import { ChatPage } from '@/pages/front/ChatPage';
|
||||
import { KnowledgePage } from '@/pages/front/KnowledgePage';
|
||||
import { ImBotHub } from '@/pages/front/ImBotHub';
|
||||
import { AppServices } from '@/pages/front/AppServices';
|
||||
import { DashboardPage } from '@/pages/front/DashboardPage';
|
||||
import { ProfilePage } from '@/pages/front/ProfilePage';
|
||||
import { MarketPage } from '@/pages/front/MarketPage';
|
||||
import { NotificationsPage } from '@/pages/front/NotificationsPage';
|
||||
import { EvolutionPage } from '@/pages/front/EvolutionPage';
|
||||
import { ComingSoon } from '@/components/common/ComingSoon';
|
||||
import {
|
||||
Bot,
|
||||
Puzzle,
|
||||
Workflow,
|
||||
Shield,
|
||||
Radio,
|
||||
} from 'lucide-react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<LandingPage />} />
|
||||
<Route path="chat" element={<ChatPage />} />
|
||||
<Route path="knowledge" element={<KnowledgePage />} />
|
||||
<Route path="im-bots" element={<ImBotHub />} />
|
||||
<Route path="apps" element={<AppServices />} />
|
||||
{/* 占位页:真实功能开发中,统一 amber 金暖白「敬请期待」态 */}
|
||||
<Route path="market" element={<MarketPage />} />
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="Agent 智能体"
|
||||
icon={Bot}
|
||||
description="可视化编排多源 Agent,组合技能、知识与工具完成复杂任务。"
|
||||
features={[
|
||||
'拖拽式 Agent 编排画布',
|
||||
'多 Agent 协作与对话路由',
|
||||
'绑定知识库与 MCP 工具',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="mcp"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="MCP 工具"
|
||||
icon={Puzzle}
|
||||
description="接入 Model Context Protocol 工具,扩展 AI 的外部执行能力。"
|
||||
features={[
|
||||
'粘贴 MCP 配置一键连接',
|
||||
'工具列表与连通性测试',
|
||||
'官方与社区 MCP 应用市场',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workflows"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="工作流"
|
||||
icon={Workflow}
|
||||
description="用可视化 DAG 编排多步骤自动化流程。"
|
||||
features={[
|
||||
'节点拖拽编排',
|
||||
'条件分支与并行执行',
|
||||
'可导出可移植的 YAML 规范',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="rules"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="规则库"
|
||||
icon={Shield}
|
||||
description="配置业务规则与审批策略,管控 AI 工具调用风险。"
|
||||
features={[
|
||||
'工具调用四级风险审批',
|
||||
'Loop 原语与验证器',
|
||||
'企业级合规策略模板',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="channels"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="渠道"
|
||||
icon={Radio}
|
||||
description="管理多渠道接入,让 AI 能力触达不同终端用户。"
|
||||
features={[
|
||||
'微信公众号 / 个人微信接入',
|
||||
'IM Bot 多端统一适配',
|
||||
'渠道级权限与配额',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="evolution" element={<EvolutionPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,767 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '/app/api';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Backend responses use a unified envelope `{code, data, message}`.
|
||||
// `request<T>()` returns the raw envelope; `data<T>()` unwraps `.data`.
|
||||
async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const env = await request<{ code: number; data: T; message: string }>(path, options);
|
||||
return env.data;
|
||||
}
|
||||
|
||||
// im_bot_user.py 专用 envelope:`{success, data, ...}`(无 code 字段)。
|
||||
async function successData<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const env = await request<{ success: boolean; data: T }>(path, options);
|
||||
return env.data;
|
||||
}
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
login: (data: { username: string; password: string }) =>
|
||||
request<{ access_token: string; token_type: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
register: (data: { username: string; email: string; password: string; code: string }) =>
|
||||
request<{ access_token: string }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
me: () => request<{ id: string; username: string; email: string; avatar_url: string }>('/auth/me'),
|
||||
|
||||
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||
|
||||
sendCode: (data: { email: string }) =>
|
||||
request('/auth/send-code', { method: 'POST', body: JSON.stringify(data) }),
|
||||
};
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (chat.py):list/messages/create 均返回 {code,data:[...]}/{code,data:{...}}
|
||||
// 列表字段:id,title,model_id,status,pinned,source,created_at,updated_at(无 message_count)
|
||||
// 消息字段:id,conversation_id,role,content,model_id,created_at,token_used
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
model_id: string;
|
||||
status?: string;
|
||||
pinned?: boolean;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
message_count?: number; // 后端未返回,前端可选兼容
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
model_id: string;
|
||||
created_at: string;
|
||||
token_used?: number;
|
||||
}
|
||||
|
||||
export const chatApi = {
|
||||
listConversations: () => data<Conversation[]>('/chat/conversations'),
|
||||
|
||||
createConversation: (payload: { title?: string; model?: string }) =>
|
||||
data<Conversation>('/chat/conversations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
deleteConversation: (id: string) =>
|
||||
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getMessages: (convId: string) =>
|
||||
data<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
|
||||
|
||||
sendMessage: (convId: string, data: { content: string; model?: string }) =>
|
||||
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
streamMessage: async function* (convId: string, data: { content: string; model?: string }) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${BASE_URL}/chat/conversations/${convId}/messages/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) return;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.content) yield data.content;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getModels: () => request<{ id: string; name: string; provider: string }[]>('/chat/models'),
|
||||
};
|
||||
|
||||
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (knowledge.py):
|
||||
// list 返回 {code,data:{bases:[...],storage_used,storage_limit,membership_level}}
|
||||
// create 返回 {code,data:{id,name,description,created_at}}(缺 document_count/status/updated_at)
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
document_count: number;
|
||||
status: 'active' | 'building' | 'error' | string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseList {
|
||||
bases: KnowledgeBase[];
|
||||
storage_used: number;
|
||||
storage_limit: number;
|
||||
membership_level: number;
|
||||
}
|
||||
|
||||
// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值)
|
||||
export interface KnowledgeBaseCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const knowledgeApi = {
|
||||
listBases: () => data<KnowledgeBaseList>('/knowledge/bases'),
|
||||
|
||||
createBase: (payload: { name: string; description?: string }) =>
|
||||
data<KnowledgeBaseCreated>('/knowledge/bases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
deleteBase: (id: string) =>
|
||||
request(`/knowledge/bases/${id}`, { method: 'DELETE' }),
|
||||
|
||||
search: (baseId: string, query: string) =>
|
||||
request<{ results: { content: string; score: number; source: string }[] }>(
|
||||
`/knowledge/bases/${baseId}/search?q=${encodeURIComponent(query)}`
|
||||
),
|
||||
|
||||
getDocuments: (baseId: string) =>
|
||||
request<{ id: string; name: string; status: string; size: number }[]>(
|
||||
`/knowledge/bases/${baseId}/documents`
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Market / Apps ──────────────────────────────────────────────────────────────
|
||||
// 后端契约:app/api/market.py → GET /apps (list_apps) 返回
|
||||
// { code:0, data:{ total, page, page_size, items:[MarketApp] } }
|
||||
// 字段以 list_apps 真实 item 为准(见 #993 重写)。
|
||||
|
||||
export type MarketAppType = 'standalone' | 'embedded' | 'skill' | 'agent' | 'workflow';
|
||||
export type MarketAppSource = 'official' | 'community' | 'third_party';
|
||||
export type MarketAppStatus = 'draft' | 'pending' | 'published' | 'rejected' | 'offline';
|
||||
|
||||
export interface MarketApp {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string | null;
|
||||
type: MarketAppType;
|
||||
source: MarketAppSource;
|
||||
publisher_id: string;
|
||||
publisher_name: string;
|
||||
is_member_free: boolean;
|
||||
join_promotion: boolean;
|
||||
status: MarketAppStatus;
|
||||
price: number;
|
||||
install_count: number;
|
||||
rating_avg: number;
|
||||
review_count: number;
|
||||
created_at: string;
|
||||
capability: string | null;
|
||||
category: string | null;
|
||||
tags: string[];
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
security_scan: string | null;
|
||||
verified: boolean;
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
// 详情页用:list 字段 + 可能更丰富的描述/截图/长文
|
||||
export interface MarketAppDetail extends MarketApp {
|
||||
long_description?: string;
|
||||
screenshots?: string[];
|
||||
homepage_url?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketReview {
|
||||
id: string;
|
||||
app_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MarketReviewList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: MarketReview[];
|
||||
}
|
||||
|
||||
export interface MarketAd {
|
||||
id: string;
|
||||
title: string;
|
||||
image_url: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MarketAppList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: MarketApp[];
|
||||
}
|
||||
|
||||
export type MarketSort = 'latest' | 'popular' | 'rating';
|
||||
|
||||
// 后端契约 (market.py /my-apps):{code,data:[{id,app_id,name,icon,type,price,purchased_at}]}
|
||||
// 与 MarketApp 字段差距大(无 installed/status/description/version),单独建模。
|
||||
export interface MyApp {
|
||||
id: string;
|
||||
app_id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: MarketAppType;
|
||||
price: number;
|
||||
purchased_at: string | null;
|
||||
}
|
||||
|
||||
export const marketApi = {
|
||||
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
|
||||
listApps: (params: {
|
||||
type?: MarketAppType;
|
||||
source?: MarketAppSource;
|
||||
capability?: string;
|
||||
search?: string;
|
||||
sort?: MarketSort;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.type) qs.set('type', params.type);
|
||||
if (params.source) qs.set('source', params.source);
|
||||
if (params.capability) qs.set('capability', params.capability);
|
||||
if (params.search) qs.set('search', params.search);
|
||||
if (params.sort) qs.set('sort', params.sort);
|
||||
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 data<MarketAppList>(`/apps${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getApp: (appId: string) =>
|
||||
data<MarketAppDetail>(`/apps/${appId}`),
|
||||
|
||||
getMyApps: () =>
|
||||
data<MyApp[]>('/my-apps'),
|
||||
|
||||
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
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 data<MarketReviewList>(`/reviews/${appId}${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getAds: () =>
|
||||
data<MarketAd[]>('/ads'),
|
||||
|
||||
// ─── 写操作(后续迭代启用)───
|
||||
installApp: (appId: string) =>
|
||||
request(`/apps/${appId}/install`, { method: 'POST' }),
|
||||
|
||||
uninstallApp: (appId: string) =>
|
||||
request(`/apps/${appId}/uninstall`, { method: 'DELETE' }),
|
||||
|
||||
purchaseApp: (appId: string) =>
|
||||
request(`/apps/${appId}/purchase`, { method: 'POST' }),
|
||||
|
||||
reviewApp: (appId: string, payload: { rating: number; comment: string }) =>
|
||||
request(`/apps/${appId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (im_bot_user.py /status):{success,data:{connected,status,configured,baseurl,ilink_bot_id,username,connected_at}}
|
||||
// 注意:data 内无 message_count / last_active(前端页面已用可选默认值兼容)。
|
||||
export interface ImBotStatus {
|
||||
status: 'connected' | 'disconnected' | 'expired' | string;
|
||||
ilink_bot_id: string | null;
|
||||
connected_at: string | null;
|
||||
last_active?: string | null;
|
||||
message_count?: number;
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
username?: string | null;
|
||||
baseurl?: string | null;
|
||||
}
|
||||
|
||||
export const imBotApi = {
|
||||
getStatus: () => successData<ImBotStatus>('/im-bot/status'),
|
||||
|
||||
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
|
||||
|
||||
checkQRStatus: (ticket: string) =>
|
||||
request<{ status: string }>('/im-bot/qrcode/status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ticket }),
|
||||
}),
|
||||
|
||||
disconnect: () => request('/im-bot/disconnect', { method: 'POST' }),
|
||||
|
||||
getHistory: () => request<{ id: string; role: string; content: string; created_at: string }[]>('/im-bot/history'),
|
||||
};
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MembershipInfo {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
start_at: string;
|
||||
expire_at: string;
|
||||
is_expired: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar: string;
|
||||
role: string;
|
||||
status: string;
|
||||
membership: MembershipInfo | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LoginLogItem {
|
||||
id: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
login_at: string;
|
||||
}
|
||||
|
||||
export interface LoginLogList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: LoginLogItem[];
|
||||
}
|
||||
|
||||
export interface ApiKeyItem {
|
||||
id: string;
|
||||
name: string;
|
||||
key_preview: string;
|
||||
status: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
expire_at: string | null;
|
||||
}
|
||||
|
||||
// 创建 API Key 时后端返回的完整 key(仅创建时返回)
|
||||
export interface ApiKeyCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
api_key: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ApiKeyStatus = 'enabled' | 'disabled' | 'revoked';
|
||||
|
||||
export interface PurchasedAppItem {
|
||||
id: string;
|
||||
app_id: string;
|
||||
app_name: string;
|
||||
app_icon: string;
|
||||
app_type: string;
|
||||
price: number;
|
||||
purchased_at: string;
|
||||
}
|
||||
|
||||
export const profileApi = {
|
||||
get: () => data<UserProfile>('/profile/'),
|
||||
|
||||
update: (payload: Partial<Pick<UserProfile, 'username' | 'email' | 'phone' | 'avatar'>>) =>
|
||||
data<UserProfile>('/profile/', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
|
||||
getBalance: () => data<number>('/profile/balance'),
|
||||
|
||||
getMembership: () => data<{
|
||||
has_membership: boolean;
|
||||
id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
start_at: string;
|
||||
expire_at: string;
|
||||
is_expired: boolean;
|
||||
}>('/profile/membership'),
|
||||
|
||||
getLoginLogs: (page = 1, page_size = 10) =>
|
||||
data<LoginLogList>(`/profile/login-logs?page=${page}&page_size=${page_size}`),
|
||||
|
||||
getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'),
|
||||
|
||||
getPurchasedApps: () => data<PurchasedAppItem[]>('/profile/purchased-apps'),
|
||||
|
||||
// ─── 写操作 ───
|
||||
|
||||
updatePassword: (payload: { old_password: string; new_password: string }) =>
|
||||
request<{ code: number; message: string }>('/profile/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
createApiKey: (name: string) =>
|
||||
data<ApiKeyCreated>('/profile/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
updateApiKey: (keyId: string, status: ApiKeyStatus) =>
|
||||
data<{ id: string; status: string }>(`/profile/api-keys/${keyId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TokenOverview {
|
||||
total_used: number;
|
||||
balance: number;
|
||||
today_apps: number;
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
export interface ModelRanking {
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
total_tokens: number;
|
||||
usage_count: number;
|
||||
}
|
||||
|
||||
export interface TopApp {
|
||||
conversation_id: string;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface TokenRankings {
|
||||
model_rankings: ModelRanking[];
|
||||
top_apps: TopApp[];
|
||||
}
|
||||
|
||||
export interface TokenLogItem {
|
||||
id: string;
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
conversation_id: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface TokenLogPage {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TokenLogItem[];
|
||||
}
|
||||
|
||||
export interface TokenBalance {
|
||||
balance: number;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export const tokenApi = {
|
||||
getOverview: () => data<TokenOverview>('/token/overview'),
|
||||
|
||||
getRankings: () => data<TokenRankings>('/token/rankings'),
|
||||
|
||||
getLogs: (params: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
model_id?: string;
|
||||
app_type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.start_date) qs.set('start_date', params.start_date);
|
||||
if (params.end_date) qs.set('end_date', params.end_date);
|
||||
if (params.model_id) qs.set('model_id', params.model_id);
|
||||
if (params.app_type) qs.set('app_type', params.app_type);
|
||||
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 data<TokenLogPage>(`/token/logs${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getBalance: () => data<TokenBalance>('/token/balance'),
|
||||
};
|
||||
|
||||
// ─── Notifications 消息通知 ──────────────────────────────────────────────────────
|
||||
// 后端契约 (notifications.py):全部 {code,data,message}
|
||||
// GET / → { total, page, page_size, items:[NotificationItem] }
|
||||
// PUT /{id}/read → {code,message}; PUT /read-all?type= → {code,data:{count},message}
|
||||
// GET /unread-count → {code,data:{count},message}
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
type: string | null;
|
||||
title: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
related_id: string | null;
|
||||
related_type: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
list: (params: { type?: string; is_read?: boolean; page?: number; page_size?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.type) qs.set('type', params.type);
|
||||
if (params.is_read !== undefined) qs.set('is_read', String(params.is_read));
|
||||
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 data<NotificationList>(`/notifications/${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
markRead: (id: string) =>
|
||||
request(`/notifications/${id}/read`, { method: 'PUT' }),
|
||||
|
||||
markAllRead: (type?: string) =>
|
||||
request(`/notifications/read-all${type ? `?type=${encodeURIComponent(type)}` : ''}`, { method: 'PUT' }),
|
||||
|
||||
unreadCount: () =>
|
||||
data<{ count: number }>('/notifications/unread-count'),
|
||||
};
|
||||
|
||||
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||
// 后端契约 (evolution.py + services/evolution_service.py):全部 {code,data,...}
|
||||
// 技能状态 state: main | staging | baby | frozen
|
||||
// 事件类型 event_type: created | edited | promoted | frozen | ...(字符串透传)
|
||||
|
||||
export type EvolutionSkillState = 'main' | 'staging' | 'baby' | 'frozen';
|
||||
|
||||
export interface EvolutionSkill {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
state: EvolutionSkillState;
|
||||
use_count: number;
|
||||
version: number;
|
||||
last_used_at: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
skill_md_content?: string | null; // 仅 detail 返回
|
||||
}
|
||||
|
||||
export interface EvolutionEvent {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
event_type: string;
|
||||
description: string;
|
||||
version_before: number | null;
|
||||
version_after: number | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionTrajectory {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
file_path: string | null;
|
||||
status: string;
|
||||
atom_count: number | null;
|
||||
token_used: number | null;
|
||||
created_at: string | null;
|
||||
processed_at: string | null;
|
||||
weightscore?: number;
|
||||
atom_id?: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionSkillTree {
|
||||
categories: { name: string; skills: EvolutionSkill[] }[];
|
||||
total_count: number;
|
||||
main_count: number;
|
||||
baby_count: number;
|
||||
staging_count: number;
|
||||
frozen_count: number;
|
||||
}
|
||||
|
||||
export interface EvolutionStatistics {
|
||||
ranking: { skill_name: string; category: string; use_count: number; state: string; last_used_at: string | null }[];
|
||||
total_use_count: number;
|
||||
category_distribution: { category: string; count: number }[];
|
||||
daily_trend: { date: string; count: number }[];
|
||||
low_use_skills: EvolutionSkill[];
|
||||
}
|
||||
|
||||
export interface EvolutionDimension {
|
||||
label: string;
|
||||
value: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
export interface EvolutionProfile {
|
||||
level: number;
|
||||
level_title: string;
|
||||
total_skills: number;
|
||||
active_skills: number;
|
||||
staging_skills: number;
|
||||
baby_skills: number;
|
||||
total_use_count: number;
|
||||
dimensions: EvolutionDimension[];
|
||||
tags: string[];
|
||||
growth_suggestions: string[];
|
||||
}
|
||||
|
||||
export interface EvolutionPipelineStatus {
|
||||
scheduler_running: boolean;
|
||||
watcher_status: string;
|
||||
pipeline_status: string;
|
||||
discovered_count: number;
|
||||
done_count: number;
|
||||
total_skills: number;
|
||||
total_trajectories: number;
|
||||
}
|
||||
|
||||
export interface EvolutionWatcherStatus {
|
||||
user_id: string;
|
||||
scheduler_running: boolean;
|
||||
watcher_status: string;
|
||||
pipeline_status: string;
|
||||
pipeline_processed: number;
|
||||
watcher_error: string | null;
|
||||
skill_count: number;
|
||||
trajectory_count: number;
|
||||
}
|
||||
|
||||
export interface EvolutionReport {
|
||||
id: string;
|
||||
report_type: string;
|
||||
period_start: string | null;
|
||||
period_end: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionReportDetail extends EvolutionReport {
|
||||
user_id: string;
|
||||
report_data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const evolutionApi = {
|
||||
// 技能管理
|
||||
listSkills: (params: { state?: string; keyword?: string; sort_by?: string; order?: string } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.state) qs.set('state', params.state);
|
||||
if (params.keyword) qs.set('keyword', params.keyword);
|
||||
if (params.sort_by) qs.set('sort_by', params.sort_by);
|
||||
if (params.order) qs.set('order', params.order);
|
||||
const q = qs.toString();
|
||||
return data<EvolutionSkill[]>(`/evolution/skills${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getSkillDetail: (skillName: string) =>
|
||||
data<EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}`),
|
||||
|
||||
// 进化事件
|
||||
listEvents: (params: { event_type?: string; skill_name?: string; limit?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.event_type) qs.set('event_type', params.event_type);
|
||||
if (params.skill_name) qs.set('skill_name', params.skill_name);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
const q = qs.toString();
|
||||
return data<EvolutionEvent[]>(`/evolution/events${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
// 驾驶舱
|
||||
getSkillTree: () => data<EvolutionSkillTree>('/evolution/dashboard/skill-tree'),
|
||||
getTimeline: (event_type?: string) =>
|
||||
data<EvolutionEvent[]>(`/evolution/dashboard/timeline${event_type ? `?event_type=${encodeURIComponent(event_type)}` : ''}`),
|
||||
getStatistics: () => data<EvolutionStatistics>('/evolution/dashboard/statistics'),
|
||||
getProfile: () => data<EvolutionProfile>('/evolution/dashboard/profile'),
|
||||
|
||||
// 流水线 / Watcher
|
||||
getPipelineStatus: () => data<EvolutionPipelineStatus>('/evolution/pipeline/status'),
|
||||
getWatcherStatus: () => data<EvolutionWatcherStatus>('/evolution/watcher/status'),
|
||||
|
||||
// 报告
|
||||
listReports: (report_type?: string) =>
|
||||
data<EvolutionReport[]>(`/evolution/reports${report_type ? `?report_type=${encodeURIComponent(report_type)}` : ''}`),
|
||||
getReportDetail: (reportId: string) =>
|
||||
data<EvolutionReportDetail>(`/evolution/reports/${reportId}`),
|
||||
};
|
||||
@@ -0,0 +1,496 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
authApi,
|
||||
chatApi,
|
||||
knowledgeApi,
|
||||
marketApi,
|
||||
imBotApi,
|
||||
profileApi,
|
||||
tokenApi,
|
||||
notificationsApi,
|
||||
evolutionApi,
|
||||
type TokenOverview,
|
||||
type MyApp,
|
||||
type MarketAd,
|
||||
type MarketSort,
|
||||
type MarketAppType,
|
||||
type MarketAppSource,
|
||||
type EvolutionSkill,
|
||||
type EvolutionEvent,
|
||||
type EvolutionReport,
|
||||
} from '@/api/client';
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) => authApi.login(data),
|
||||
onSuccess: (res) => {
|
||||
localStorage.setItem('auth_token', res.access_token);
|
||||
qc.invalidateQueries({ queryKey: ['auth', 'me'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: authApi.me,
|
||||
retry: false,
|
||||
enabled: !!localStorage.getItem('auth_token'),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useConversations() {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'conversations'],
|
||||
queryFn: chatApi.listConversations,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMessages(convId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'messages', convId],
|
||||
queryFn: () => chatApi.getMessages(convId!),
|
||||
enabled: !!convId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateConversation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { title?: string; model?: string }) => chatApi.createConversation(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendMessage(convId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { content: string; model?: string }) => chatApi.sendMessage(convId, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'messages', convId] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConversation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => chatApi.deleteConversation(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useModels() {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'models'],
|
||||
queryFn: chatApi.getModels,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useKnowledgeBases() {
|
||||
return useQuery({
|
||||
queryKey: ['knowledge', 'bases'],
|
||||
queryFn: knowledgeApi.listBases,
|
||||
select: (d) => {
|
||||
const bases = Array.isArray(d?.bases) ? d.bases : [];
|
||||
// 后端 create 返回缺 document_count/status/updated_at,这里补默认值避免渲染崩溃
|
||||
return bases.map((kb) => ({
|
||||
...kb,
|
||||
document_count: kb.document_count ?? 0,
|
||||
status: kb.status ?? 'active',
|
||||
updated_at: kb.updated_at ?? kb.created_at ?? '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateKnowledgeBase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; description?: string }) => knowledgeApi.createBase(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteKnowledgeBase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => knowledgeApi.deleteBase(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeSearch(baseId: string | null, query: string) {
|
||||
return useQuery({
|
||||
queryKey: ['knowledge', 'search', baseId, query],
|
||||
queryFn: () => knowledgeApi.search(baseId!, query),
|
||||
enabled: !!baseId && query.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Market / Apps ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Backend list endpoints may return a bare array, `{data:[...]}`, or an error
|
||||
// object. Coerce to a real array so consumers can always call .slice/.map/.filter.
|
||||
function asArray<T>(v: unknown): T[] {
|
||||
if (Array.isArray(v)) return v as T[];
|
||||
if (v && typeof v === 'object' && Array.isArray((v as any).data)) return (v as any).data;
|
||||
if (v && typeof v === 'object' && Array.isArray((v as any).items)) return (v as any).items;
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface MarketAppsParams {
|
||||
type?: MarketAppType;
|
||||
source?: MarketAppSource;
|
||||
capability?: string;
|
||||
search?: string;
|
||||
sort?: MarketSort;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export function useMarketApps(params: MarketAppsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'apps', params],
|
||||
queryFn: () => marketApi.listApps(params),
|
||||
placeholderData: (prev) => prev, // 翻页/筛选时保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarketApp(appId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'app', appId],
|
||||
queryFn: () => marketApi.getApp(appId!),
|
||||
enabled: !!appId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyApps() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'my-apps'],
|
||||
queryFn: marketApi.getMyApps,
|
||||
select: (d: MyApp[]) => Array.isArray(d) ? d : [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarketReviews(appId: string | null, params: { page?: number; page_size?: number } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'reviews', appId, params],
|
||||
queryFn: () => marketApi.getReviews(appId!, params),
|
||||
enabled: !!appId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarketAds() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'ads'],
|
||||
queryFn: marketApi.getAds,
|
||||
select: (d: unknown) => asArray<MarketAd>(d),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallApp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (appId: string) => marketApi.installApp(appId),
|
||||
onSuccess: (_res, appId) => {
|
||||
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'app', appId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallApp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (appId: string) => marketApi.uninstallApp(appId),
|
||||
onSuccess: (_res, appId) => {
|
||||
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'app', appId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useImBotStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['im-bot', 'status'],
|
||||
queryFn: imBotApi.getStatus,
|
||||
refetchInterval: 30000, // poll every 30s
|
||||
});
|
||||
}
|
||||
|
||||
export function useImBotQRCode() {
|
||||
return useQuery({
|
||||
queryKey: ['im-bot', 'qrcode'],
|
||||
queryFn: imBotApi.getQRCode,
|
||||
enabled: false, // only on demand
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisconnectBot() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => imBotApi.disconnect(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['im-bot', 'status'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useProfile() {
|
||||
return useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: profileApi.get,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Parameters<typeof profileApi.update>[0]) => profileApi.update(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileBalance() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'balance'],
|
||||
queryFn: profileApi.getBalance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileMembership() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'membership'],
|
||||
queryFn: profileApi.getMembership,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileLoginLogs(page = 1, page_size = 10) {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'login-logs', page, page_size],
|
||||
queryFn: () => profileApi.getLoginLogs(page, page_size),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileApiKeys() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'api-keys'],
|
||||
queryFn: profileApi.getApiKeys,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfilePurchasedApps() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'purchased-apps'],
|
||||
queryFn: profileApi.getPurchasedApps,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Profile 写操作 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useUpdateProfilePassword() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { old_password: string; new_password: string }) =>
|
||||
profileApi.updatePassword(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => profileApi.createApiKey(name),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ keyId, status }: { keyId: string; status: 'enabled' | 'disabled' | 'revoked' }) =>
|
||||
profileApi.updateApiKey(keyId, status),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Token 驾驶舱 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Landing hero still shows a few headline numbers; reuse the real token overview
|
||||
// so the homepage never ships fake/mock values.
|
||||
export function useDashboardStats() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'overview'],
|
||||
queryFn: tokenApi.getOverview,
|
||||
select: (d: TokenOverview) => ({
|
||||
active_users: d.today_apps,
|
||||
today_conversations: d.today_apps,
|
||||
skill_count: d.total_used,
|
||||
revenue: d.balance,
|
||||
user_change: '',
|
||||
conv_change: '',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenOverview() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'overview'],
|
||||
queryFn: tokenApi.getOverview,
|
||||
refetchInterval: 60000, // 每分钟刷新一次概览
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenRankings() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'rankings'],
|
||||
queryFn: tokenApi.getRankings,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenBalance() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'balance'],
|
||||
queryFn: tokenApi.getBalance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenLogs(params: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
model_id?: string;
|
||||
app_type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'logs', params],
|
||||
queryFn: () => tokenApi.getLogs(params),
|
||||
placeholderData: (prev) => prev, // 翻页时保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Notifications 消息通知 ─────────────────────────────────────────────────────
|
||||
|
||||
export function useNotifications(params: {
|
||||
type?: string;
|
||||
is_read?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'list', params],
|
||||
queryFn: () => notificationsApi.list(params),
|
||||
placeholderData: (prev) => prev, // 翻页/筛选保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount() {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: notificationsApi.unreadCount,
|
||||
refetchInterval: 60000, // 每分钟刷新未读计数
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkNotificationRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationsApi.markRead(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllNotificationsRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (type?: string) => notificationsApi.markAllRead(type),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||
|
||||
export function useEvolutionSkills(params: {
|
||||
state?: string;
|
||||
keyword?: string;
|
||||
sort_by?: string;
|
||||
order?: string;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'skills', params],
|
||||
queryFn: () => evolutionApi.listSkills(params),
|
||||
select: (d: EvolutionSkill[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionEvents(params: {
|
||||
event_type?: string;
|
||||
skill_name?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'events', params],
|
||||
queryFn: () => evolutionApi.listEvents(params),
|
||||
select: (d: EvolutionEvent[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionSkillTree() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'skill-tree'],
|
||||
queryFn: evolutionApi.getSkillTree,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionStatistics() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'statistics'],
|
||||
queryFn: evolutionApi.getStatistics,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionProfile() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'profile'],
|
||||
queryFn: evolutionApi.getProfile,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionPipelineStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'pipeline-status'],
|
||||
queryFn: evolutionApi.getPipelineStatus,
|
||||
refetchInterval: 30000, // 流水线状态每 30s 轮询
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionWatcherStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'watcher-status'],
|
||||
queryFn: evolutionApi.getWatcherStatus,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionReports(report_type?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'reports', report_type],
|
||||
queryFn: () => evolutionApi.listReports(report_type),
|
||||
select: (d: EvolutionReport[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
AppWindow,
|
||||
Plus,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Globe,
|
||||
Monitor,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useMyApps } from '@/hooks/useApi';
|
||||
import type { MyApp } from '@/api/client';
|
||||
|
||||
export function AppServices() {
|
||||
const { data: apps = [], isLoading } = useMyApps();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">应用中心</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理已安装的 AI 应用</p>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 创建应用
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<AppWindow size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-1">暂无应用</p>
|
||||
<p className="text-sm">去应用市场浏览并安装应用</p>
|
||||
</div>
|
||||
) : (
|
||||
apps.map((app: MyApp) => (
|
||||
<Card key={app.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
{app.icon ? (
|
||||
<img src={app.icon} alt={app.name} className="w-6 h-6 rounded" />
|
||||
) : app.type === 'standalone' ? (
|
||||
<Globe size={20} className="text-primary" />
|
||||
) : (
|
||||
<Monitor size={20} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="success">已安装</Badge>
|
||||
<Badge variant="outline">{app.type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-foreground mb-2">{app.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{app.price > 0 ? `¥${app.price.toFixed(2)}` : '免费'}
|
||||
{app.purchased_at ? ` · 购买于 ${new Date(app.purchased_at).toLocaleDateString('zh-CN')}` : ''}
|
||||
</p>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Settings size={14} /> 配置
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
variant="default"
|
||||
>
|
||||
<ExternalLink size={14} /> 打开
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Create New */}
|
||||
<Card className="border-dashed hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[220px]">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
|
||||
<Plus size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">创建新应用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Send,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
Copy,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useConversations,
|
||||
useMessages,
|
||||
useCreateConversation,
|
||||
useSendMessage,
|
||||
useDeleteConversation,
|
||||
} from '@/hooks/useApi';
|
||||
import type { Conversation, ChatMessage } from '@/api/client';
|
||||
|
||||
export function ChatPage() {
|
||||
const [selectedConv, setSelectedConv] = useState<string | null>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
// API hooks
|
||||
const { data: conversations = [], isLoading: convsLoading } = useConversations();
|
||||
const { data: messages = [], isLoading: msgsLoading } = useMessages(selectedConv);
|
||||
const createConv = useCreateConversation();
|
||||
const sendMessage = useSendMessage(selectedConv || '');
|
||||
const deleteConv = useDeleteConversation();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const conv = await createConv.mutateAsync({ title: '新对话' });
|
||||
setSelectedConv(conv.id);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !selectedConv) return;
|
||||
const content = input;
|
||||
setInput('');
|
||||
setStreamingContent('');
|
||||
|
||||
try {
|
||||
// Try streaming first
|
||||
const stream = chatApi.streamMessage(selectedConv, { content });
|
||||
let accumulated = '';
|
||||
for await (const chunk of stream) {
|
||||
accumulated += chunk;
|
||||
setStreamingContent(accumulated);
|
||||
}
|
||||
setStreamingContent('');
|
||||
// Refresh messages
|
||||
} catch {
|
||||
// Fallback to regular send
|
||||
await sendMessage.mutateAsync({ content });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteConv.mutateAsync(id);
|
||||
if (selectedConv === id) setSelectedConv(null);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] gap-4">
|
||||
{/* Conversation List */}
|
||||
<div className="w-72 flex-shrink-0 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索对话..."
|
||||
className="h-9 w-full rounded-lg bg-muted pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mx-3 mt-3 gap-2"
|
||||
variant="outline"
|
||||
onClick={handleNewChat}
|
||||
disabled={createConv.isPending}
|
||||
>
|
||||
<Plus size={16} /> 新建对话
|
||||
</Button>
|
||||
|
||||
<ScrollArea className="flex-1 px-2 py-2">
|
||||
<div className="space-y-1">
|
||||
{convsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv: Conversation) => (
|
||||
<div key={conv.id} className="group relative">
|
||||
<button
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
|
||||
selectedConv === conv.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{conv.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 ml-5">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{conv.model_id || 'Auto'}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(conv.id); }}
|
||||
className="absolute right-2 top-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
|
||||
>
|
||||
<Trash2 size={12} className="text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{!selectedConv ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">开始与 AI 对话吧</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天
|
||||
</p>
|
||||
<Button className="mt-4 gap-2" onClick={handleNewChat}>
|
||||
<Plus size={16} /> 新建对话
|
||||
</Button>
|
||||
</div>
|
||||
) : msgsLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{messages.map((msg: ChatMessage) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
}`}>
|
||||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<Copy size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<ThumbsUp size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<ThumbsDown size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<RotateCcw size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{streamingContent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="max-w-[80%] rounded-xl px-4 py-3 bg-muted text-foreground">
|
||||
<div className="text-sm whitespace-pre-wrap">{streamingContent}</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.2s]" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||
placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'}
|
||||
className="flex-1"
|
||||
disabled={!selectedConv || sendMessage.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !selectedConv || sendMessage.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{sendMessage.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles size={12} /> Auto(自动匹配)
|
||||
</div>
|
||||
<button className="p-1 hover:bg-muted rounded transition-colors">
|
||||
<Settings size={16} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Import for streaming
|
||||
import { chatApi } from '@/api/client';
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Sparkles,
|
||||
Brain,
|
||||
Network,
|
||||
BarChart3,
|
||||
Activity,
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
Cpu,
|
||||
ArrowUpRight,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useEvolutionProfile,
|
||||
useEvolutionSkills,
|
||||
useEvolutionStatistics,
|
||||
useEvolutionPipelineStatus,
|
||||
useEvolutionEvents,
|
||||
} from '@/hooks/useApi';
|
||||
import type {
|
||||
EvolutionSkill,
|
||||
EvolutionSkillState,
|
||||
EvolutionStatistics,
|
||||
EvolutionProfile,
|
||||
EvolutionPipelineStatus,
|
||||
EvolutionEvent,
|
||||
} from '@/api/client';
|
||||
|
||||
// 技能状态 → 中文标签 + 颜色
|
||||
const STATE_LABELS: Record<EvolutionSkillState, string> = {
|
||||
main: '成熟',
|
||||
staging: '灰度',
|
||||
baby: '萌芽',
|
||||
frozen: '冻结',
|
||||
};
|
||||
|
||||
const STATE_BADGE: Record<EvolutionSkillState, 'default' | 'secondary' | 'outline' | 'success'> = {
|
||||
main: 'success',
|
||||
staging: 'default',
|
||||
baby: 'secondary',
|
||||
frozen: 'outline',
|
||||
};
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// 雷达/维度条
|
||||
function DimensionBars({ dimensions }: { dimensions: EvolutionProfile['dimensions'] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{dimensions.map((dim) => {
|
||||
const pct = Math.round((dim.value / dim.maxValue) * 100);
|
||||
return (
|
||||
<div key={dim.label}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-muted-foreground">{dim.label}</span>
|
||||
<span className="font-medium text-foreground">{dim.value.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 能力画像卡
|
||||
function ProfileCard({ profile }: { profile: EvolutionProfile }) {
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-amber-50/40 dark:to-amber-950/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Sparkles size={30} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-bold text-foreground">
|
||||
Lv.{profile.level} {profile.level_title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
共 {profile.total_skills} 个技能 · 累计调用 {profile.total_use_count} 次
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-5" />
|
||||
|
||||
<DimensionBars dimensions={profile.dimensions} />
|
||||
|
||||
{profile.growth_suggestions.length > 0 && (
|
||||
<div className="mt-5 space-y-2">
|
||||
<p className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
<TrendingUp size={15} className="text-primary" />
|
||||
成长建议
|
||||
</p>
|
||||
{profile.growth_suggestions.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<ArrowUpRight size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
<span>{s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-4">
|
||||
{profile.tags.map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 技能网格
|
||||
function SkillCard({ skill }: { skill: EvolutionSkill }) {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Cpu size={17} className="text-primary" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground truncate">{skill.skill_name}</h4>
|
||||
</div>
|
||||
<Badge variant={STATE_BADGE[skill.state] ?? 'outline'} className="shrink-0">
|
||||
{STATE_LABELS[skill.state] ?? skill.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2rem]">
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>调用 {skill.use_count}</span>
|
||||
<span>v{skill.version}</span>
|
||||
</div>
|
||||
{skill.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{skill.tags.slice(0, 3).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px] py-0">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 流水线状态条
|
||||
function PipelineStatusCard({ status }: { status: EvolutionPipelineStatus }) {
|
||||
const running = status.scheduler_running;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">进化流水线</h3>
|
||||
<Badge
|
||||
variant={running ? 'success' : 'outline'}
|
||||
className="ml-auto gap-1"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'w-1.5 h-1.5 rounded-full ' +
|
||||
(running ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground')
|
||||
}
|
||||
/>
|
||||
{running ? '运行中' : '未运行'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
|
||||
<Stat label="待处理轨迹" value={status.discovered_count} />
|
||||
<Stat label="已完成轨迹" value={status.done_count} />
|
||||
<Stat label="技能总数" value={status.total_skills} />
|
||||
<Stat label="轨迹总数" value={status.total_trajectories} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
流水线状态:{status.pipeline_status} · 引擎:{status.watcher_status}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/50 py-3">
|
||||
<p className="text-xl font-bold text-foreground">{value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 统计卡(Top 技能 + 类别分布)
|
||||
function StatisticsCard({ stats }: { stats: EvolutionStatistics }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">使用统计</h3>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
总调用 {stats.total_use_count}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Top 技能 */}
|
||||
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
|
||||
<TrendingUp size={14} className="text-primary" />
|
||||
热门技能 Top {stats.ranking.length}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{stats.ranking.slice(0, 5).map((r, i) => {
|
||||
const max = stats.ranking[0]?.use_count || 1;
|
||||
const pct = Math.round((r.use_count / max) * 100);
|
||||
return (
|
||||
<div key={r.skill_name}>
|
||||
<div className="flex items-center justify-between text-xs mb-0.5">
|
||||
<span className="text-muted-foreground truncate">
|
||||
{i + 1}. {r.skill_name}
|
||||
</span>
|
||||
<span className="font-medium text-foreground shrink-0 ml-2">
|
||||
{r.use_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stats.ranking.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">暂无使用数据</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* 类别分布 */}
|
||||
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
|
||||
<Layers size={14} className="text-primary" />
|
||||
类别分布
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.category_distribution.map((c) => (
|
||||
<Badge key={c.category} variant="secondary" className="gap-1">
|
||||
{c.category}
|
||||
<span className="text-primary font-medium">{c.count}</span>
|
||||
</Badge>
|
||||
))}
|
||||
{stats.category_distribution.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">暂无分布数据</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 事件流
|
||||
function EventFeed({ events }: { events: EvolutionEvent[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<GitBranch size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">进化事件流</h3>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无进化事件</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-start gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-primary mt-2 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground">{e.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{e.skill_name}
|
||||
{e.version_after != null && ` · v${e.version_after}`} ·{' '}
|
||||
{formatTime(e.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{e.event_type}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function EvolutionPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const { data: profile, isLoading: pLoading } = useEvolutionProfile();
|
||||
const { data: skills, isLoading: sLoading } = useEvolutionSkills();
|
||||
const { data: stats } = useEvolutionStatistics();
|
||||
const { data: pipeline } = useEvolutionPipelineStatus();
|
||||
const { data: events } = useEvolutionEvents({ limit: 30 });
|
||||
|
||||
const skillList: EvolutionSkill[] = Array.isArray(skills) ? skills : [];
|
||||
// 按 state 分组
|
||||
const grouped = useGroupedSkills(skillList);
|
||||
const isLoading = pLoading || sLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Brain size={20} className="text-primary" />
|
||||
AI 自进化
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
平台从对话与工具调用中持续学习,自动沉淀与迭代你的专属技能
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
>
|
||||
<RefreshCw size={15} className={refreshKey ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={refreshKey}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 能力画像 + 流水线状态 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{profile ? (
|
||||
<ProfileCard profile={profile} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
<Brain size={40} className="mx-auto mb-3 opacity-50" />
|
||||
暂无能力数据,多使用 AI 即可激活自进化
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{pipeline && <PipelineStatusCard status={pipeline} />}
|
||||
</div>
|
||||
|
||||
{/* 统计 + 事件流 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{stats && <StatisticsCard stats={stats} />}
|
||||
{events && <EventFeed events={events} />}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 技能图谱 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Network size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">技能图谱</h3>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{skillList.length} 个技能
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{skillList.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Network size={44} className="mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">还没有进化出技能,继续与 AI 对话吧</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{grouped.map((g) => (
|
||||
<div key={g.category}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{g.category}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{g.skills.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{g.skills.map((sk) => (
|
||||
<SkillCard key={sk.id} skill={sk} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按 category 分组,保持顺序稳定
|
||||
function useGroupedSkills(skills: EvolutionSkill[]): { category: string; skills: EvolutionSkill[] }[] {
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, EvolutionSkill[]>();
|
||||
for (const s of skills) {
|
||||
const cat = s.category || '未分类';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat)!.push(s);
|
||||
}
|
||||
return Array.from(map.entries()).map(([category, list]) => ({ category, skills: list }));
|
||||
}, [skills]);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
QrCode,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useImBotStatus, useDisconnectBot } from '@/hooks/useApi';
|
||||
|
||||
const otherChannels = [
|
||||
{ id: '2', name: '企业微信', icon: '🏢', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
{ id: '3', name: '飞书', icon: '📨', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
{ id: '4', name: 'QQ', icon: '🐧', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
];
|
||||
|
||||
export function ImBotHub() {
|
||||
const { data: wechatStatus, isLoading } = useImBotStatus();
|
||||
const disconnectBot = useDisconnectBot();
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!confirm('确定断开微信连接?')) return;
|
||||
try {
|
||||
await disconnectBot.mutateAsync();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">渠道管理</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">配置和管理您的 IM 渠道</p>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 添加渠道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* WeChat Bot - Real Data */}
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-2xl">
|
||||
💬
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">个人微信</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{wechatStatus?.ilink_bot_id || '未配置'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={wechatStatus?.status === 'connected' ? 'success' : 'secondary'}>
|
||||
{isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : wechatStatus?.status === 'connected' ? (
|
||||
<span className="flex items-center gap-1"><Wifi size={12} /> 已连接</span>
|
||||
) : wechatStatus?.status === 'expired' ? (
|
||||
<span className="flex items-center gap-1 text-amber-600"><AlertTriangle size={12} /> 已过期</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{wechatStatus?.status === 'connected' && (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={14} /> {wechatStatus.message_count || 0} 条消息
|
||||
</span>
|
||||
<span>
|
||||
连接于 {wechatStatus.connected_at ? new Date(wechatStatus.connected_at).toLocaleString('zh-CN') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wechatStatus?.status === 'expired' && (
|
||||
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm mb-4">
|
||||
会话已过期,请重新扫码连接
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{wechatStatus?.status === 'connected' ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Settings size={14} /> 管理
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ExternalLink size={14} /> 查看对话
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnectBot.isPending}
|
||||
>
|
||||
{disconnectBot.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
断开连接
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" className="gap-1">
|
||||
<QrCode size={14} /> 扫码绑定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Other Channels - Mock */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
{otherChannels.map((channel) => (
|
||||
<Card key={channel.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center text-2xl">
|
||||
{channel.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{channel.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">未配置</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="gap-1">
|
||||
<QrCode size={14} /> 扫码绑定
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Upload,
|
||||
FileText,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
useKnowledgeBases,
|
||||
useCreateKnowledgeBase,
|
||||
useDeleteKnowledgeBase,
|
||||
} from '@/hooks/useApi';
|
||||
import type { KnowledgeBase } from '@/api/client';
|
||||
|
||||
export function KnowledgePage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
const { data: bases = [], isLoading } = useKnowledgeBases();
|
||||
const createBase = useCreateKnowledgeBase();
|
||||
const deleteBase = useDeleteKnowledgeBase();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await createBase.mutateAsync({ name: newName, description: newDesc });
|
||||
setShowCreateDialog(false);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除此知识库?')) return;
|
||||
try {
|
||||
await deleteBase.mutateAsync(id);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="在所有知识库中检索问题..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
支持跨库检索 · 向量相似度排序 · 来源溯源
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="all">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部知识库</TabsTrigger>
|
||||
<TabsTrigger value="recent">最近使用</TabsTrigger>
|
||||
</TabsList>
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 新建知识库
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建知识库</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
placeholder="知识库名称"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
placeholder="简要描述(可选)"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim() || createBase.isPending}
|
||||
>
|
||||
{createBase.isPending ? (
|
||||
<><Loader2 size={16} className="animate-spin" /> 创建中...</>
|
||||
) : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="mt-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
bases.map((kb: KnowledgeBase) => (
|
||||
<Card key={kb.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Database size={20} className="text-primary" />
|
||||
</div>
|
||||
<Badge variant={kb.status === 'active' ? 'success' : 'secondary'}>
|
||||
{kb.status === 'active' ? '运行中' : kb.status === 'building' ? '构建中' : '异常'}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground mb-1">{kb.name}</h3>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText size={14} /> {kb.document_count} 文档
|
||||
</span>
|
||||
</div>
|
||||
{kb.description && (
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">{kb.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
更新于 {new Date(kb.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
|
||||
<Upload size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
|
||||
<RefreshCw size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(kb.id)}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
disabled={deleteBase.isPending}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Add New Card */}
|
||||
<Card
|
||||
className="border-dashed hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
>
|
||||
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[200px]">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
|
||||
<Plus size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">新建知识库</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Search,
|
||||
Store,
|
||||
Star,
|
||||
Download,
|
||||
Check,
|
||||
Sparkles,
|
||||
Globe,
|
||||
Bot,
|
||||
Workflow,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
Crown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useMarketApps,
|
||||
useMarketAds,
|
||||
type MarketAppsParams,
|
||||
} from '@/hooks/useApi';
|
||||
import type {
|
||||
MarketApp,
|
||||
MarketAppType,
|
||||
MarketAppSource,
|
||||
MarketSort,
|
||||
} from '@/api/client';
|
||||
|
||||
const TYPE_LABELS: Record<MarketAppType, string> = {
|
||||
standalone: '独立应用',
|
||||
embedded: '嵌入式',
|
||||
skill: '技能',
|
||||
agent: '智能体',
|
||||
workflow: '工作流',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<MarketAppType, typeof Globe> = {
|
||||
standalone: Globe,
|
||||
embedded: Puzzle,
|
||||
skill: Sparkles,
|
||||
agent: Bot,
|
||||
workflow: Workflow,
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<MarketAppSource, string> = {
|
||||
official: '官方',
|
||||
community: '社区',
|
||||
third_party: '第三方',
|
||||
};
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 10000) return `${(n / 10000).toFixed(1)}w`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatPrice(app: MarketApp): { label: string; free: boolean } {
|
||||
if (app.is_member_free) return { label: '会员免费', free: true };
|
||||
if (app.price > 0) return { label: `¥${app.price}`, free: false };
|
||||
return { label: '免费', free: true };
|
||||
}
|
||||
|
||||
export function MarketPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [type, setType] = useState<MarketAppType | undefined>(undefined);
|
||||
const [sort, setSort] = useState<MarketSort>('popular');
|
||||
const [page, setPage] = useState(1);
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
// 简单防抖搜索(300ms)
|
||||
useMemo(() => {
|
||||
const t = setTimeout(() => setDebounced(search.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const params: MarketAppsParams = useMemo(
|
||||
() => ({
|
||||
type,
|
||||
search: debounced || undefined,
|
||||
sort,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
}),
|
||||
[type, debounced, sort, page]
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching } = useMarketApps(params);
|
||||
const { data: ads } = useMarketAds();
|
||||
|
||||
const items: MarketApp[] = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const typeFilters: (MarketAppType | undefined)[] = [
|
||||
undefined,
|
||||
'skill',
|
||||
'agent',
|
||||
'workflow',
|
||||
'standalone',
|
||||
'embedded',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Store size={20} className="text-primary" />
|
||||
技能市场
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
浏览、安装官方与社区技能,一键接入平台能力
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 广告位 */}
|
||||
{ads && ads.length > 0 && (
|
||||
<a
|
||||
href={ads[0].url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-xl overflow-hidden border border-primary/20 bg-gradient-to-r from-primary/10 to-amber-50 dark:to-amber-950/30"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Sparkles size={22} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{ads[0].title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">点击查看详情 →</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 搜索 + 排序 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="搜索技能、智能体、工作流…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(['popular', 'latest', 'rating'] as MarketSort[]).map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={sort === s ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setSort(s);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{s === 'popular' ? '最热' : s === 'latest' ? '最新' : '评分'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型筛选 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{typeFilters.map((tf) => (
|
||||
<Button
|
||||
key={tf ?? 'all'}
|
||||
size="sm"
|
||||
variant={type === tf ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setType(tf);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{tf ? TYPE_LABELS[tf] : '全部'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Store size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-1">未找到匹配的技能</p>
|
||||
<p className="text-sm">试试调整搜索词或筛选条件</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{items.map((app: MarketApp) => {
|
||||
const TypeIcon = TYPE_ICONS[app.type] ?? Sparkles;
|
||||
const price = formatPrice(app);
|
||||
return (
|
||||
<Card
|
||||
key={app.id}
|
||||
className="hover:shadow-md transition-shadow flex flex-col"
|
||||
>
|
||||
<CardContent className="p-5 flex flex-col flex-1">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-11 h-11 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
{app.icon ? (
|
||||
<img
|
||||
src={app.icon}
|
||||
alt={app.name}
|
||||
className="w-7 h-7 rounded"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon size={22} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{app.verified && (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<ShieldCheck size={11} />
|
||||
已认证
|
||||
</Badge>
|
||||
)}
|
||||
{app.installed ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check size={11} />
|
||||
已安装
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{SOURCE_LABELS[app.source]}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground truncate">{app.name}</h3>
|
||||
{app.is_member_free && (
|
||||
<Crown size={14} className="text-amber-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
由 {app.publisher_name} 发布
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-4 flex-1">
|
||||
{app.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
|
||||
{app.rating_avg > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Star size={13} className="text-amber-500 fill-amber-500" />
|
||||
{app.rating_avg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Download size={13} />
|
||||
{formatCount(app.install_count)}
|
||||
</span>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{TYPE_LABELS[app.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-3" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={
|
||||
price.free
|
||||
? 'text-sm font-medium text-green-600 dark:text-green-400'
|
||||
: 'text-sm font-semibold text-foreground'
|
||||
}
|
||||
>
|
||||
{price.label}
|
||||
</span>
|
||||
{app.installed ? (
|
||||
<Button size="sm" variant="outline" disabled className="gap-1">
|
||||
<Check size={14} />
|
||||
已安装
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="gap-1">
|
||||
<Download size={14} />
|
||||
安装
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1 || isFetching}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages || isFetching}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Check,
|
||||
Circle,
|
||||
Loader2,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
useMarkNotificationRead,
|
||||
useMarkAllNotificationsRead,
|
||||
} from '@/hooks/useApi';
|
||||
import type { NotificationItem } from '@/api/client';
|
||||
|
||||
// 通知类型 → 中文标签(后端 type 透传,未知类型回退「通知」)
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
task: '任务',
|
||||
billing: '账单',
|
||||
system: '系统',
|
||||
evolution: '自进化',
|
||||
market: '市场',
|
||||
};
|
||||
|
||||
function typeLabel(t: string | null): string {
|
||||
if (!t) return '通知';
|
||||
return TYPE_LABELS[t] ?? t;
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return '刚刚';
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr} 小时前`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 7) return `${day} 天前`;
|
||||
return d.toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [isRead, setIsRead] = useState<boolean | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const { data, isLoading, isFetching } = useNotifications({
|
||||
is_read: isRead,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
const { data: unread } = useUnreadCount();
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
|
||||
const items: NotificationItem[] = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const unreadCount = unread?.count ?? 0;
|
||||
|
||||
// 筛选 tabs:全部 / 未读 / 已读
|
||||
const filters: { key: string; label: string; value: boolean | undefined }[] = [
|
||||
{ key: 'all', label: '全部', value: undefined },
|
||||
{ key: 'unread', label: '未读', value: false },
|
||||
{ key: 'read', label: '已读', value: true },
|
||||
];
|
||||
|
||||
const busy = markRead.isPending || markAllRead.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Bell size={20} className="text-primary" />
|
||||
消息通知
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20 ml-1">
|
||||
{unreadCount} 未读
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
集中查看系统通知、任务动态与配额告警
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={unreadCount === 0 || busy}
|
||||
onClick={() => markAllRead.mutate(undefined)}
|
||||
>
|
||||
<CheckCheck size={15} />
|
||||
全部已读
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选 tabs */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter size={15} className="text-muted-foreground" />
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f.key}
|
||||
size="sm"
|
||||
variant={isRead === f.value ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setIsRead(f.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Bell size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-1">暂无通知</p>
|
||||
<p className="text-sm">
|
||||
{isRead === false ? '所有通知都已读啦' : '系统有动态时会在这里提醒你'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{items.map((n: NotificationItem) => (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={
|
||||
'transition-colors ' +
|
||||
(n.is_read ? 'bg-card' : 'bg-primary/5 border-primary/20')
|
||||
}
|
||||
>
|
||||
<CardContent className="p-4 flex items-start gap-3">
|
||||
{/* 已读/未读指示 */}
|
||||
<div className="mt-1 shrink-0">
|
||||
{n.is_read ? (
|
||||
<Check size={16} className="text-muted-foreground" />
|
||||
) : (
|
||||
<Circle size={10} className="fill-primary text-primary mt-1.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{typeLabel(n.type)}
|
||||
</Badge>
|
||||
<h3 className="font-medium text-foreground truncate">
|
||||
{n.title}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{formatTime(n.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{n.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!n.is_read && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 gap-1 text-primary hover:text-primary"
|
||||
disabled={markRead.isPending}
|
||||
onClick={() => markRead.mutate(n.id)}
|
||||
>
|
||||
<Check size={14} />
|
||||
标为已读
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1 || isFetching}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages || isFetching}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>七虎AI - 养龙虾与AI能力探索</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4577
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "qihuai-frontend",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.29",
|
||||
"terser": "^5.31.0",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#64748b"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-size="18" font-weight="bold" fill="white">七</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
@@ -0,0 +1,208 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { LoginPage } from '@/pages/front/LoginPage';
|
||||
import { LandingPage } from '@/pages/front/LandingPage';
|
||||
import { ChatPage } from '@/pages/front/ChatPage';
|
||||
import { KnowledgePage } from '@/pages/front/KnowledgePage';
|
||||
import { ImBotHub } from '@/pages/front/ImBotHub';
|
||||
import { AppServices } from '@/pages/front/AppServices';
|
||||
import { DashboardPage } from '@/pages/front/DashboardPage';
|
||||
import { ProfilePage } from '@/pages/front/ProfilePage';
|
||||
import { MarketPage } from '@/pages/front/MarketPage';
|
||||
import { NotificationsPage } from '@/pages/front/NotificationsPage';
|
||||
import { EvolutionPage } from '@/pages/front/EvolutionPage';
|
||||
import { ComingSoon } from '@/components/common/ComingSoon';
|
||||
import {
|
||||
Bot,
|
||||
Puzzle,
|
||||
Workflow,
|
||||
Shield,
|
||||
Radio,
|
||||
} from 'lucide-react';
|
||||
import { useCurrentUser } from '@/hooks/useApi';
|
||||
import { AdminLayout } from '@/pages/admin/AdminLayout';
|
||||
import { AdminDashboard } from '@/pages/admin/AdminDashboard';
|
||||
import {
|
||||
UsersPage,
|
||||
RolesPage,
|
||||
TenantPage,
|
||||
ModelsPage,
|
||||
KnowledgeAdminPage,
|
||||
AssetsPage,
|
||||
StoragePage,
|
||||
AgentsAdminPage,
|
||||
McpPage,
|
||||
McpMarketPage,
|
||||
SkillsAdminPage,
|
||||
WorkflowAdminPage,
|
||||
AutomationPage,
|
||||
AppsAdminPage,
|
||||
CodeExecPage,
|
||||
DistributionsPage,
|
||||
AdsPage,
|
||||
MarketAdminPage,
|
||||
PaymentPage,
|
||||
BillingPage,
|
||||
MembershipPage,
|
||||
ServicesPage,
|
||||
LogsPage,
|
||||
DeveloperPage,
|
||||
AnalyticsPage,
|
||||
} from '@/pages/admin/AdminPlaceholders';
|
||||
|
||||
// 管理员守卫:复用 frontend-v2 的 auth_token 认证链,role=admin|super_admin 放行。
|
||||
function AdminGuard({ children }: { children: React.ReactNode }) {
|
||||
const { data: user, isLoading } = useCurrentUser();
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
if (!token) return <Navigate to="/login" replace />;
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen flex items-center justify-center text-muted-foreground">加载中…</div>;
|
||||
}
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||
if (!isAdmin) return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<LandingPage />} />
|
||||
<Route path="chat" element={<ChatPage />} />
|
||||
<Route path="knowledge" element={<KnowledgePage />} />
|
||||
<Route path="im-bots" element={<ImBotHub />} />
|
||||
<Route path="apps" element={<AppServices />} />
|
||||
{/* 占位页:真实功能开发中,统一 amber 金暖白「敬请期待」态 */}
|
||||
<Route path="market" element={<MarketPage />} />
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="Agent 智能体"
|
||||
icon={Bot}
|
||||
description="可视化编排多源 Agent,组合技能、知识与工具完成复杂任务。"
|
||||
features={[
|
||||
'拖拽式 Agent 编排画布',
|
||||
'多 Agent 协作与对话路由',
|
||||
'绑定知识库与 MCP 工具',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="mcp"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="MCP 工具"
|
||||
icon={Puzzle}
|
||||
description="接入 Model Context Protocol 工具,扩展 AI 的外部执行能力。"
|
||||
features={[
|
||||
'粘贴 MCP 配置一键连接',
|
||||
'工具列表与连通性测试',
|
||||
'官方与社区 MCP 应用市场',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workflows"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="工作流"
|
||||
icon={Workflow}
|
||||
description="用可视化 DAG 编排多步骤自动化流程。"
|
||||
features={[
|
||||
'节点拖拽编排',
|
||||
'条件分支与并行执行',
|
||||
'可导出可移植的 YAML 规范',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="rules"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="规则库"
|
||||
icon={Shield}
|
||||
description="配置业务规则与审批策略,管控 AI 工具调用风险。"
|
||||
features={[
|
||||
'工具调用四级风险审批',
|
||||
'Loop 原语与验证器',
|
||||
'企业级合规策略模板',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="channels"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="渠道"
|
||||
icon={Radio}
|
||||
description="管理多渠道接入,让 AI 能力触达不同终端用户。"
|
||||
features={[
|
||||
'微信公众号 / 个人微信接入',
|
||||
'IM Bot 多端统一适配',
|
||||
'渠道级权限与配额',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="evolution" element={<EvolutionPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin 后台:管理员守卫 + AdminLayout 外壳 + 嵌套二级路由 */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminGuard>
|
||||
<AdminLayout />
|
||||
</AdminGuard>
|
||||
}
|
||||
>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="roles" element={<RolesPage />} />
|
||||
<Route path="tenant" element={<TenantPage />} />
|
||||
<Route path="models" element={<ModelsPage />} />
|
||||
<Route path="knowledge" element={<KnowledgeAdminPage />} />
|
||||
<Route path="assets" element={<AssetsPage />} />
|
||||
<Route path="storage" element={<StoragePage />} />
|
||||
<Route path="agents" element={<AgentsAdminPage />} />
|
||||
<Route path="mcp" element={<McpPage />} />
|
||||
<Route path="mcp-market" element={<McpMarketPage />} />
|
||||
<Route path="skills" element={<SkillsAdminPage />} />
|
||||
<Route path="workflow" element={<WorkflowAdminPage />} />
|
||||
<Route path="automation" element={<AutomationPage />} />
|
||||
<Route path="apps" element={<AppsAdminPage />} />
|
||||
<Route path="code-exec" element={<CodeExecPage />} />
|
||||
<Route path="distributions" element={<DistributionsPage />} />
|
||||
<Route path="ads" element={<AdsPage />} />
|
||||
<Route path="market" element={<MarketAdminPage />} />
|
||||
<Route path="payment" element={<PaymentPage />} />
|
||||
<Route path="billing" element={<BillingPage />} />
|
||||
<Route path="membership" element={<MembershipPage />} />
|
||||
<Route path="services" element={<ServicesPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="developer" element={<DeveloperPage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,816 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '/app/api';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Backend responses use a unified envelope `{code, data, message}`.
|
||||
// `request<T>()` returns the raw envelope; `data<T>()` unwraps `.data`.
|
||||
async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const env = await request<{ code: number; data: T; message: string }>(path, options);
|
||||
return env.data;
|
||||
}
|
||||
|
||||
// im_bot_user.py 专用 envelope:`{success, data, ...}`(无 code 字段)。
|
||||
async function successData<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const env = await request<{ success: boolean; data: T }>(path, options);
|
||||
return env.data;
|
||||
}
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
login: (data: { username: string; password: string }) =>
|
||||
request<{ access_token: string; token_type: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
register: (data: { username: string; email: string; password: string; code: string }) =>
|
||||
request<{ access_token: string }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
me: () => request<{ id: string; username: string; email: string; avatar_url: string; role?: string }>('/auth/me'),
|
||||
|
||||
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||
|
||||
sendCode: (data: { email: string }) =>
|
||||
request('/auth/send-code', { method: 'POST', body: JSON.stringify(data) }),
|
||||
};
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (chat.py):list/messages/create 均返回 {code,data:[...]}/{code,data:{...}}
|
||||
// 列表字段:id,title,model_id,status,pinned,source,created_at,updated_at(无 message_count)
|
||||
// 消息字段:id,conversation_id,role,content,model_id,created_at,token_used
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
model_id: string;
|
||||
status?: string;
|
||||
pinned?: boolean;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
message_count?: number; // 后端未返回,前端可选兼容
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
model_id: string;
|
||||
created_at: string;
|
||||
token_used?: number;
|
||||
}
|
||||
|
||||
export const chatApi = {
|
||||
listConversations: () => data<Conversation[]>('/chat/conversations'),
|
||||
|
||||
createConversation: (payload: { title?: string; model?: string }) =>
|
||||
data<Conversation>('/chat/conversations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
deleteConversation: (id: string) =>
|
||||
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getMessages: (convId: string) =>
|
||||
data<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
|
||||
|
||||
sendMessage: (convId: string, data: { content: string; model?: string }) =>
|
||||
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
streamMessage: async function* (convId: string, data: { content: string; model?: string }) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${BASE_URL}/chat/conversations/${convId}/messages/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) return;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.content) yield data.content;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getModels: () => request<{ id: string; name: string; provider: string }[]>('/chat/models'),
|
||||
};
|
||||
|
||||
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (knowledge.py):
|
||||
// list 返回 {code,data:{bases:[...],storage_used,storage_limit,membership_level}}
|
||||
// create 返回 {code,data:{id,name,description,created_at}}(缺 document_count/status/updated_at)
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
document_count: number;
|
||||
status: 'active' | 'building' | 'error' | string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseList {
|
||||
bases: KnowledgeBase[];
|
||||
storage_used: number;
|
||||
storage_limit: number;
|
||||
membership_level: number;
|
||||
}
|
||||
|
||||
// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值)
|
||||
export interface KnowledgeBaseCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const knowledgeApi = {
|
||||
listBases: () => data<KnowledgeBaseList>('/knowledge/bases'),
|
||||
|
||||
createBase: (payload: { name: string; description?: string }) =>
|
||||
data<KnowledgeBaseCreated>('/knowledge/bases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
deleteBase: (id: string) =>
|
||||
request(`/knowledge/bases/${id}`, { method: 'DELETE' }),
|
||||
|
||||
search: (baseId: string, query: string) =>
|
||||
request<{ results: { content: string; score: number; source: string }[] }>(
|
||||
`/knowledge/bases/${baseId}/search?q=${encodeURIComponent(query)}`
|
||||
),
|
||||
|
||||
getDocuments: (baseId: string) =>
|
||||
request<{ id: string; name: string; status: string; size: number }[]>(
|
||||
`/knowledge/bases/${baseId}/documents`
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Market / Apps ──────────────────────────────────────────────────────────────
|
||||
// 后端契约:app/api/market.py → GET /apps (list_apps) 返回
|
||||
// { code:0, data:{ total, page, page_size, items:[MarketApp] } }
|
||||
// 字段以 list_apps 真实 item 为准(见 #993 重写)。
|
||||
|
||||
export type MarketAppType = 'standalone' | 'embedded' | 'skill' | 'agent' | 'workflow';
|
||||
export type MarketAppSource = 'official' | 'community' | 'third_party';
|
||||
export type MarketAppStatus = 'draft' | 'pending' | 'published' | 'rejected' | 'offline';
|
||||
|
||||
export interface MarketApp {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string | null;
|
||||
type: MarketAppType;
|
||||
source: MarketAppSource;
|
||||
publisher_id: string;
|
||||
publisher_name: string;
|
||||
is_member_free: boolean;
|
||||
join_promotion: boolean;
|
||||
status: MarketAppStatus;
|
||||
price: number;
|
||||
install_count: number;
|
||||
rating_avg: number;
|
||||
review_count: number;
|
||||
created_at: string;
|
||||
capability: string | null;
|
||||
category: string | null;
|
||||
tags: string[];
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
security_scan: string | null;
|
||||
verified: boolean;
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
// 详情页用:list 字段 + 可能更丰富的描述/截图/长文
|
||||
export interface MarketAppDetail extends MarketApp {
|
||||
long_description?: string;
|
||||
screenshots?: string[];
|
||||
homepage_url?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketReview {
|
||||
id: string;
|
||||
app_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MarketReviewList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: MarketReview[];
|
||||
}
|
||||
|
||||
export interface MarketAd {
|
||||
id: string;
|
||||
title: string;
|
||||
image_url: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MarketAppList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: MarketApp[];
|
||||
}
|
||||
|
||||
export type MarketSort = 'latest' | 'popular' | 'rating';
|
||||
|
||||
// 后端契约 (market.py /my-apps):{code,data:[{id,app_id,name,icon,type,price,purchased_at}]}
|
||||
// 与 MarketApp 字段差距大(无 installed/status/description/version),单独建模。
|
||||
export interface MyApp {
|
||||
id: string;
|
||||
app_id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: MarketAppType;
|
||||
price: number;
|
||||
purchased_at: string | null;
|
||||
}
|
||||
|
||||
export const marketApi = {
|
||||
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
|
||||
listApps: (params: {
|
||||
type?: MarketAppType;
|
||||
source?: MarketAppSource;
|
||||
capability?: string;
|
||||
search?: string;
|
||||
sort?: MarketSort;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.type) qs.set('type', params.type);
|
||||
if (params.source) qs.set('source', params.source);
|
||||
if (params.capability) qs.set('capability', params.capability);
|
||||
if (params.search) qs.set('search', params.search);
|
||||
if (params.sort) qs.set('sort', params.sort);
|
||||
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 data<MarketAppList>(`/apps${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getApp: (appId: string) =>
|
||||
data<MarketAppDetail>(`/apps/${appId}`),
|
||||
|
||||
getMyApps: () =>
|
||||
data<MyApp[]>('/my-apps'),
|
||||
|
||||
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
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 data<MarketReviewList>(`/reviews/${appId}${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getAds: () =>
|
||||
data<MarketAd[]>('/ads'),
|
||||
|
||||
// ─── 写操作(后续迭代启用)───
|
||||
installApp: (appId: string) =>
|
||||
request(`/apps/${appId}/install`, { method: 'POST' }),
|
||||
|
||||
uninstallApp: (appId: string) =>
|
||||
request(`/apps/${appId}/uninstall`, { method: 'DELETE' }),
|
||||
|
||||
purchaseApp: (appId: string) =>
|
||||
request(`/apps/${appId}/purchase`, { method: 'POST' }),
|
||||
|
||||
reviewApp: (appId: string, payload: { rating: number; comment: string }) =>
|
||||
request(`/apps/${appId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (im_bot_user.py /status):{success,data:{connected,status,configured,baseurl,ilink_bot_id,username,connected_at}}
|
||||
// 注意:data 内无 message_count / last_active(前端页面已用可选默认值兼容)。
|
||||
export interface ImBotStatus {
|
||||
status: 'connected' | 'disconnected' | 'expired' | string;
|
||||
ilink_bot_id: string | null;
|
||||
connected_at: string | null;
|
||||
last_active?: string | null;
|
||||
message_count?: number;
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
username?: string | null;
|
||||
baseurl?: string | null;
|
||||
}
|
||||
|
||||
export const imBotApi = {
|
||||
getStatus: () => successData<ImBotStatus>('/im-bot/status'),
|
||||
|
||||
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
|
||||
|
||||
checkQRStatus: (ticket: string) =>
|
||||
request<{ status: string }>('/im-bot/qrcode/status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ticket }),
|
||||
}),
|
||||
|
||||
disconnect: () => request('/im-bot/disconnect', { method: 'POST' }),
|
||||
|
||||
getHistory: () => request<{ id: string; role: string; content: string; created_at: string }[]>('/im-bot/history'),
|
||||
};
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MembershipInfo {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
start_at: string;
|
||||
expire_at: string;
|
||||
is_expired: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar: string;
|
||||
role: string;
|
||||
status: string;
|
||||
membership: MembershipInfo | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LoginLogItem {
|
||||
id: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
login_at: string;
|
||||
}
|
||||
|
||||
export interface LoginLogList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: LoginLogItem[];
|
||||
}
|
||||
|
||||
export interface ApiKeyItem {
|
||||
id: string;
|
||||
name: string;
|
||||
key_preview: string;
|
||||
status: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
expire_at: string | null;
|
||||
}
|
||||
|
||||
// 创建 API Key 时后端返回的完整 key(仅创建时返回)
|
||||
export interface ApiKeyCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
api_key: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ApiKeyStatus = 'enabled' | 'disabled' | 'revoked';
|
||||
|
||||
export interface PurchasedAppItem {
|
||||
id: string;
|
||||
app_id: string;
|
||||
app_name: string;
|
||||
app_icon: string;
|
||||
app_type: string;
|
||||
price: number;
|
||||
purchased_at: string;
|
||||
}
|
||||
|
||||
export const profileApi = {
|
||||
get: () => data<UserProfile>('/profile/'),
|
||||
|
||||
update: (payload: Partial<Pick<UserProfile, 'username' | 'email' | 'phone' | 'avatar'>>) =>
|
||||
data<UserProfile>('/profile/', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
|
||||
getBalance: () => data<number>('/profile/balance'),
|
||||
|
||||
getMembership: () => data<{
|
||||
has_membership: boolean;
|
||||
id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
start_at: string;
|
||||
expire_at: string;
|
||||
is_expired: boolean;
|
||||
}>('/profile/membership'),
|
||||
|
||||
getLoginLogs: (page = 1, page_size = 10) =>
|
||||
data<LoginLogList>(`/profile/login-logs?page=${page}&page_size=${page_size}`),
|
||||
|
||||
getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'),
|
||||
|
||||
getPurchasedApps: () => data<PurchasedAppItem[]>('/profile/purchased-apps'),
|
||||
|
||||
// ─── 写操作 ───
|
||||
|
||||
updatePassword: (payload: { old_password: string; new_password: string }) =>
|
||||
request<{ code: number; message: string }>('/profile/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
createApiKey: (name: string) =>
|
||||
data<ApiKeyCreated>('/profile/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
updateApiKey: (keyId: string, status: ApiKeyStatus) =>
|
||||
data<{ id: string; status: string }>(`/profile/api-keys/${keyId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TokenOverview {
|
||||
total_used: number;
|
||||
balance: number;
|
||||
today_apps: number;
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
export interface ModelRanking {
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
total_tokens: number;
|
||||
usage_count: number;
|
||||
}
|
||||
|
||||
export interface TopApp {
|
||||
conversation_id: string;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface TokenRankings {
|
||||
model_rankings: ModelRanking[];
|
||||
top_apps: TopApp[];
|
||||
}
|
||||
|
||||
export interface TokenLogItem {
|
||||
id: string;
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
conversation_id: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface TokenLogPage {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TokenLogItem[];
|
||||
}
|
||||
|
||||
export interface TokenBalance {
|
||||
balance: number;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export const tokenApi = {
|
||||
getOverview: () => data<TokenOverview>('/token/overview'),
|
||||
|
||||
getRankings: () => data<TokenRankings>('/token/rankings'),
|
||||
|
||||
getLogs: (params: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
model_id?: string;
|
||||
app_type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.start_date) qs.set('start_date', params.start_date);
|
||||
if (params.end_date) qs.set('end_date', params.end_date);
|
||||
if (params.model_id) qs.set('model_id', params.model_id);
|
||||
if (params.app_type) qs.set('app_type', params.app_type);
|
||||
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 data<TokenLogPage>(`/token/logs${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getBalance: () => data<TokenBalance>('/token/balance'),
|
||||
};
|
||||
|
||||
// ─── Notifications 消息通知 ──────────────────────────────────────────────────────
|
||||
// 后端契约 (notifications.py):全部 {code,data,message}
|
||||
// GET / → { total, page, page_size, items:[NotificationItem] }
|
||||
// PUT /{id}/read → {code,message}; PUT /read-all?type= → {code,data:{count},message}
|
||||
// GET /unread-count → {code,data:{count},message}
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
type: string | null;
|
||||
title: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
related_id: string | null;
|
||||
related_type: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
list: (params: { type?: string; is_read?: boolean; page?: number; page_size?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.type) qs.set('type', params.type);
|
||||
if (params.is_read !== undefined) qs.set('is_read', String(params.is_read));
|
||||
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 data<NotificationList>(`/notifications/${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
markRead: (id: string) =>
|
||||
request(`/notifications/${id}/read`, { method: 'PUT' }),
|
||||
|
||||
markAllRead: (type?: string) =>
|
||||
request(`/notifications/read-all${type ? `?type=${encodeURIComponent(type)}` : ''}`, { method: 'PUT' }),
|
||||
|
||||
unreadCount: () =>
|
||||
data<{ count: number }>('/notifications/unread-count'),
|
||||
};
|
||||
|
||||
// ─── Admin 后台 ──────────────────────────────────────────────────────────────────
|
||||
// 后端契约 (app/api/admin/*):绝大多数端点返回统一信封 {code,data,message},
|
||||
// 但 dashboard.py 的 `/admin/dashboard/stats` 返回**裸 dict**(无 code/data 包裹),
|
||||
// `/admin/dashboard/daily-stats` 返回 {code,data:{days,data}}。
|
||||
// 因此 adminApi 不复用 data<T>(),改用 rawAdmin<T>() 解包并按端点分别处理 envelope。
|
||||
|
||||
export interface AdminDashboardStats {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_models: number;
|
||||
enabled_models: number;
|
||||
total_conversations: number;
|
||||
active_conversations: number;
|
||||
valid_conversations: number;
|
||||
total_knowledge_bases: number;
|
||||
total_documents: number;
|
||||
today_orders: number;
|
||||
today_subscription_orders: number;
|
||||
today_tokens: number;
|
||||
week_tokens: number;
|
||||
total_tokens: number;
|
||||
today_conversations: number;
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
export interface AdminDailyStat {
|
||||
date: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
export interface AdminDailyStats {
|
||||
days: string[];
|
||||
data: AdminDailyStat[];
|
||||
}
|
||||
|
||||
// 统一信封解包(与 data<T>() 相同语义,但显式标记 admin 域,便于后续差异演进)
|
||||
async function adminData<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const env = await request<{ code: number; data: T; message: string }>(path, options);
|
||||
return env.data;
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
// /stats 裸 dict(无 code/data 包裹)→ 走 raw request,直接返回 json
|
||||
getStats: () => request<AdminDashboardStats>('/admin/dashboard/stats'),
|
||||
|
||||
// /daily-stats 标准信封 {code,data:{days,data}}
|
||||
getDailyStats: () => adminData<AdminDailyStats>('/admin/dashboard/daily-stats'),
|
||||
};
|
||||
|
||||
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||
// 后端契约 (evolution.py + services/evolution_service.py):全部 {code,data,...}
|
||||
// 技能状态 state: main | staging | baby | frozen
|
||||
// 事件类型 event_type: created | edited | promoted | frozen | ...(字符串透传)
|
||||
|
||||
export type EvolutionSkillState = 'main' | 'staging' | 'baby' | 'frozen';
|
||||
|
||||
export interface EvolutionSkill {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
state: EvolutionSkillState;
|
||||
use_count: number;
|
||||
version: number;
|
||||
last_used_at: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
skill_md_content?: string | null; // 仅 detail 返回
|
||||
}
|
||||
|
||||
export interface EvolutionEvent {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
event_type: string;
|
||||
description: string;
|
||||
version_before: number | null;
|
||||
version_after: number | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionTrajectory {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
file_path: string | null;
|
||||
status: string;
|
||||
atom_count: number | null;
|
||||
token_used: number | null;
|
||||
created_at: string | null;
|
||||
processed_at: string | null;
|
||||
weightscore?: number;
|
||||
atom_id?: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionSkillTree {
|
||||
categories: { name: string; skills: EvolutionSkill[] }[];
|
||||
total_count: number;
|
||||
main_count: number;
|
||||
baby_count: number;
|
||||
staging_count: number;
|
||||
frozen_count: number;
|
||||
}
|
||||
|
||||
export interface EvolutionStatistics {
|
||||
ranking: { skill_name: string; category: string; use_count: number; state: string; last_used_at: string | null }[];
|
||||
total_use_count: number;
|
||||
category_distribution: { category: string; count: number }[];
|
||||
daily_trend: { date: string; count: number }[];
|
||||
low_use_skills: EvolutionSkill[];
|
||||
}
|
||||
|
||||
export interface EvolutionDimension {
|
||||
label: string;
|
||||
value: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
export interface EvolutionProfile {
|
||||
level: number;
|
||||
level_title: string;
|
||||
total_skills: number;
|
||||
active_skills: number;
|
||||
staging_skills: number;
|
||||
baby_skills: number;
|
||||
total_use_count: number;
|
||||
dimensions: EvolutionDimension[];
|
||||
tags: string[];
|
||||
growth_suggestions: string[];
|
||||
}
|
||||
|
||||
export interface EvolutionPipelineStatus {
|
||||
scheduler_running: boolean;
|
||||
watcher_status: string;
|
||||
pipeline_status: string;
|
||||
discovered_count: number;
|
||||
done_count: number;
|
||||
total_skills: number;
|
||||
total_trajectories: number;
|
||||
}
|
||||
|
||||
export interface EvolutionWatcherStatus {
|
||||
user_id: string;
|
||||
scheduler_running: boolean;
|
||||
watcher_status: string;
|
||||
pipeline_status: string;
|
||||
pipeline_processed: number;
|
||||
watcher_error: string | null;
|
||||
skill_count: number;
|
||||
trajectory_count: number;
|
||||
}
|
||||
|
||||
export interface EvolutionReport {
|
||||
id: string;
|
||||
report_type: string;
|
||||
period_start: string | null;
|
||||
period_end: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionReportDetail extends EvolutionReport {
|
||||
user_id: string;
|
||||
report_data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const evolutionApi = {
|
||||
// 技能管理
|
||||
listSkills: (params: { state?: string; keyword?: string; sort_by?: string; order?: string } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.state) qs.set('state', params.state);
|
||||
if (params.keyword) qs.set('keyword', params.keyword);
|
||||
if (params.sort_by) qs.set('sort_by', params.sort_by);
|
||||
if (params.order) qs.set('order', params.order);
|
||||
const q = qs.toString();
|
||||
return data<EvolutionSkill[]>(`/evolution/skills${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getSkillDetail: (skillName: string) =>
|
||||
data<EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}`),
|
||||
|
||||
// 进化事件
|
||||
listEvents: (params: { event_type?: string; skill_name?: string; limit?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.event_type) qs.set('event_type', params.event_type);
|
||||
if (params.skill_name) qs.set('skill_name', params.skill_name);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
const q = qs.toString();
|
||||
return data<EvolutionEvent[]>(`/evolution/events${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
// 驾驶舱
|
||||
getSkillTree: () => data<EvolutionSkillTree>('/evolution/dashboard/skill-tree'),
|
||||
getTimeline: (event_type?: string) =>
|
||||
data<EvolutionEvent[]>(`/evolution/dashboard/timeline${event_type ? `?event_type=${encodeURIComponent(event_type)}` : ''}`),
|
||||
getStatistics: () => data<EvolutionStatistics>('/evolution/dashboard/statistics'),
|
||||
getProfile: () => data<EvolutionProfile>('/evolution/dashboard/profile'),
|
||||
|
||||
// 流水线 / Watcher
|
||||
getPipelineStatus: () => data<EvolutionPipelineStatus>('/evolution/pipeline/status'),
|
||||
getWatcherStatus: () => data<EvolutionWatcherStatus>('/evolution/watcher/status'),
|
||||
|
||||
// 报告
|
||||
listReports: (report_type?: string) =>
|
||||
data<EvolutionReport[]>(`/evolution/reports${report_type ? `?report_type=${encodeURIComponent(report_type)}` : ''}`),
|
||||
getReportDetail: (reportId: string) =>
|
||||
data<EvolutionReportDetail>(`/evolution/reports/${reportId}`),
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Sparkles, Construction } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface ComingSoonProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
description?: string;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一「页面开发中」占位页(amber 金暖白设计语言)。
|
||||
* 用于前台尚未实现真实功能的路由,提供有品牌感的「敬请期待」状态,
|
||||
* 而非空白或纯文字提示,保证导航点击不出现断崖式空页面。
|
||||
*/
|
||||
export function ComingSoon({ title, icon: Icon, description, features }: ComingSoonProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] px-4">
|
||||
<Card className="w-full max-w-lg text-center border-border">
|
||||
<CardContent className="p-10">
|
||||
{/* 图标芯片 */}
|
||||
<div className="mx-auto mb-6 w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||
<Icon size={30} className="text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<h2 className="text-2xl font-bold text-foreground">{title}</h2>
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20 hover:bg-primary/10">
|
||||
<Construction size={12} className="mr-1" />
|
||||
开发中
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed mb-6">
|
||||
{description ?? `${title} 正在紧张开发中,敬请期待。上线后这里将提供完整的功能体验。`}
|
||||
</p>
|
||||
|
||||
{features && features.length > 0 && (
|
||||
<div className="text-left space-y-2 mb-6">
|
||||
{features.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Sparkles size={15} className="text-primary mt-0.5 shrink-0" />
|
||||
<span>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground/70 bg-muted rounded-full px-3 py-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
我们将尽快为您带来 {title}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Bell, Search } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
export function Header({ title, showSearch = false }: HeaderProps) {
|
||||
return (
|
||||
<header className="h-16 border-b border-border bg-white dark:bg-gray-950 flex items-center justify-between px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{title && <h1 className="text-lg font-semibold text-foreground">{title}</h1>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
className="h-9 w-[240px] rounded-lg border border-border bg-muted px-9 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="relative p-2 rounded-lg hover:bg-muted text-muted-foreground transition-colors">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
</button>
|
||||
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src="/avatar.png" alt="用户" />
|
||||
<AvatarFallback>U</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
<div className="ml-[240px]">
|
||||
<Header />
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
Zap,
|
||||
Bot,
|
||||
Puzzle,
|
||||
Workflow,
|
||||
Shield,
|
||||
Radio,
|
||||
AppWindow,
|
||||
LayoutDashboard,
|
||||
User,
|
||||
Bell,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: 'AI 能力',
|
||||
items: [
|
||||
{ path: '/chat', label: 'AI 聊天', icon: <MessageSquare size={20} /> },
|
||||
{ path: '/knowledge', label: '知识库', icon: <BookOpen size={20} /> },
|
||||
{ path: '/market', label: '技能市场', icon: <Zap size={20} /> },
|
||||
{ path: '/agents', label: 'Agent', icon: <Bot size={20} /> },
|
||||
{ path: '/mcp', label: 'MCP 工具', icon: <Puzzle size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'AI 资源',
|
||||
items: [
|
||||
{ path: '/workflows', label: '工作流', icon: <Workflow size={20} /> },
|
||||
{ path: '/rules', label: '规则库', icon: <Shield size={20} /> },
|
||||
{ path: '/channels', label: '渠道', icon: <Radio size={20} /> },
|
||||
{ path: '/apps', label: '应用中心', icon: <AppWindow size={20} /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const bottomItems: NavItem[] = [
|
||||
{ path: '/dashboard', label: '驾驶舱', icon: <LayoutDashboard size={20} /> },
|
||||
{ path: '/profile', label: '个人中心', icon: <User size={20} /> },
|
||||
{ path: '/notifications', label: '消息通知', icon: <Bell size={20} /> },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className={cn(
|
||||
'fixed left-0 top-0 z-40 h-screen border-r border-border bg-white dark:bg-gray-950 transition-all duration-300 flex flex-col',
|
||||
collapsed ? 'w-[68px]' : 'w-[240px]'
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-border">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">七</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">七虎AI</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 overflow-y-auto py-4 px-2">
|
||||
{navGroups.map((group, i) => (
|
||||
<div key={i} className="mb-4">
|
||||
{!collapsed && (
|
||||
<div className="px-3 mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{group.title}
|
||||
</div>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<div className="border-t border-border py-2 px-2">
|
||||
{bottomItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)} {...props} />
|
||||
)
|
||||
);
|
||||
Avatar.displayName = 'Avatar';
|
||||
|
||||
const AvatarImage = React.forwardRef<HTMLImageElement, React.ImgHTMLAttributes<HTMLImageElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<img ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AvatarImage.displayName = 'AvatarImage';
|
||||
|
||||
const AvatarFallback = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AvatarFallback.displayName = 'AvatarFallback';
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Badge = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { variant?: 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'destructive' }>(
|
||||
({ className, variant = 'default', ...props }, ref) => {
|
||||
const variants = {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
warning: 'border-transparent bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
destructive: 'border-transparent bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export { Badge };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
|
||||
outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-lg px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border border-border bg-card text-card-foreground shadow-sm', className)} {...props} />
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ComponentRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef<React.ComponentRef<typeof ScrollAreaPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
);
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>>(
|
||||
({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
);
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<React.ComponentRef<typeof TabsPrimitive.List>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<React.ComponentRef<typeof TabsPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<React.ComponentRef<typeof TabsPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<React.ComponentRef<typeof TooltipPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>>(
|
||||
({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn('z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
authApi,
|
||||
chatApi,
|
||||
knowledgeApi,
|
||||
marketApi,
|
||||
imBotApi,
|
||||
profileApi,
|
||||
tokenApi,
|
||||
type TokenOverview,
|
||||
} from '@/api/client';
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) => authApi.login(data),
|
||||
onSuccess: (res) => {
|
||||
localStorage.setItem('auth_token', res.access_token);
|
||||
qc.invalidateQueries({ queryKey: ['auth', 'me'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: authApi.me,
|
||||
retry: false,
|
||||
enabled: !!localStorage.getItem('auth_token'),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useConversations() {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'conversations'],
|
||||
queryFn: chatApi.listConversations,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMessages(convId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'messages', convId],
|
||||
queryFn: () => chatApi.getMessages(convId!),
|
||||
enabled: !!convId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateConversation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { title?: string; model?: string }) => chatApi.createConversation(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendMessage(convId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { content: string; model?: string }) => chatApi.sendMessage(convId, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'messages', convId] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConversation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => chatApi.deleteConversation(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useModels() {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'models'],
|
||||
queryFn: chatApi.getModels,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useKnowledgeBases() {
|
||||
return useQuery({
|
||||
queryKey: ['knowledge', 'bases'],
|
||||
queryFn: knowledgeApi.listBases,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateKnowledgeBase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; description?: string }) => knowledgeApi.createBase(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteKnowledgeBase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => knowledgeApi.deleteBase(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeSearch(baseId: string | null, query: string) {
|
||||
return useQuery({
|
||||
queryKey: ['knowledge', 'search', baseId, query],
|
||||
queryFn: () => knowledgeApi.search(baseId!, query),
|
||||
enabled: !!baseId && query.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Market / Apps ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Backend list endpoints may return a bare array, `{data:[...]}`, or an error
|
||||
// object. Coerce to a real array so consumers can always call .slice/.map/.filter.
|
||||
function asArray<T>(v: unknown): T[] {
|
||||
if (Array.isArray(v)) return v as T[];
|
||||
if (v && typeof v === 'object' && Array.isArray((v as any).data)) return (v as any).data;
|
||||
if (v && typeof v === 'object' && Array.isArray((v as any).items)) return (v as any).items;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function useMarketApps() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'apps'],
|
||||
queryFn: marketApi.listApps,
|
||||
select: (d: unknown) => asArray(d),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyApps() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'my-apps'],
|
||||
queryFn: marketApi.getMyApps,
|
||||
select: (d: unknown) => asArray(d),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallApp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (appId: string) => marketApi.installApp(appId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useImBotStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['im-bot', 'status'],
|
||||
queryFn: imBotApi.getStatus,
|
||||
refetchInterval: 30000, // poll every 30s
|
||||
});
|
||||
}
|
||||
|
||||
export function useImBotQRCode() {
|
||||
return useQuery({
|
||||
queryKey: ['im-bot', 'qrcode'],
|
||||
queryFn: imBotApi.getQRCode,
|
||||
enabled: false, // only on demand
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisconnectBot() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => imBotApi.disconnect(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['im-bot', 'status'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useProfile() {
|
||||
return useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: profileApi.get,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Parameters<typeof profileApi.update>[0]) => profileApi.update(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileBalance() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'balance'],
|
||||
queryFn: profileApi.getBalance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileMembership() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'membership'],
|
||||
queryFn: profileApi.getMembership,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileLoginLogs(page = 1, page_size = 10) {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'login-logs', page, page_size],
|
||||
queryFn: () => profileApi.getLoginLogs(page, page_size),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileApiKeys() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'api-keys'],
|
||||
queryFn: profileApi.getApiKeys,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfilePurchasedApps() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'purchased-apps'],
|
||||
queryFn: profileApi.getPurchasedApps,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Profile 写操作 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useUpdateProfilePassword() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { old_password: string; new_password: string }) =>
|
||||
profileApi.updatePassword(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => profileApi.createApiKey(name),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ keyId, status }: { keyId: string; status: 'enabled' | 'disabled' | 'revoked' }) =>
|
||||
profileApi.updateApiKey(keyId, status),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Token 驾驶舱 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Landing hero still shows a few headline numbers; reuse the real token overview
|
||||
// so the homepage never ships fake/mock values.
|
||||
export function useDashboardStats() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'overview'],
|
||||
queryFn: tokenApi.getOverview,
|
||||
select: (d: TokenOverview) => ({
|
||||
active_users: d.today_apps,
|
||||
today_conversations: d.today_apps,
|
||||
skill_count: d.total_used,
|
||||
revenue: d.balance,
|
||||
user_change: '',
|
||||
conv_change: '',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenOverview() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'overview'],
|
||||
queryFn: tokenApi.getOverview,
|
||||
refetchInterval: 60000, // 每分钟刷新一次概览
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenRankings() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'rankings'],
|
||||
queryFn: tokenApi.getRankings,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenBalance() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'balance'],
|
||||
queryFn: tokenApi.getBalance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenLogs(params: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
model_id?: string;
|
||||
app_type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'logs', params],
|
||||
queryFn: () => tokenApi.getLogs(params),
|
||||
placeholderData: (prev) => prev, // 翻页时保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import './styles/globals.css';
|
||||
|
||||
function Home() {
|
||||
return <div style={{ padding: '20px' }}><h1>首页</h1></div>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './styles/globals.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||||
<h1>七虎AI</h1>
|
||||
<p>测试页面 - 如果看到这个说明 React 正常工作</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/globals.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Sparkles, Construction } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface AdminComingSoonProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
description?: string;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* admin 后台统一「模块开发中」占位页(amber 金暖白)。
|
||||
* 旧 admin 共 33 个二级模块,初期仅 Dashboard 为真实数据页,
|
||||
* 其余模块用此占位,保证后台导航点击不出现空白断崖。
|
||||
*/
|
||||
export function AdminComingSoon({ title, icon: Icon, description, features }: AdminComingSoonProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] px-4">
|
||||
<Card className="w-full max-w-lg text-center border-border">
|
||||
<CardContent className="p-10">
|
||||
<div className="mx-auto mb-6 w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||
<Icon size={30} className="text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<h2 className="text-2xl font-bold text-foreground">{title}</h2>
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20 hover:bg-primary/10">
|
||||
<Construction size={12} className="mr-1" />
|
||||
建设中
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground leading-relaxed mb-6">
|
||||
{description ?? `${title} 模块正在开发中,敬请期待。上线后这里将提供完整的后台管理能力。`}
|
||||
</p>
|
||||
|
||||
{features && features.length > 0 && (
|
||||
<div className="text-left space-y-2 mb-6">
|
||||
{features.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles size={15} className="text-primary mt-0.5 shrink-0" />
|
||||
<span>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inline-flex items-center gap-2 text-xs text-muted-foreground/70 bg-muted rounded-full px-3 py-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
我们将尽快为您带来 {title}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi, type AdminDailyStat } from '@/api/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useCurrentUser } from '@/hooks/useApi';
|
||||
import { Users, Cpu, MessageSquare, BookOpen, Coins, Activity, TrendingUp } from 'lucide-react';
|
||||
|
||||
function formatNumber(n: number | undefined): string {
|
||||
if (n === undefined || n === null) return '—';
|
||||
return n.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatBjTime(iso: string | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
// 北京时间 = UTC+8
|
||||
const bj = new Date(d.getTime() + 8 * 3600 * 1000);
|
||||
const p = (x: number) => String(x).padStart(2, '0');
|
||||
return `${bj.getUTCFullYear()}-${p(bj.getUTCMonth() + 1)}-${p(bj.getUTCDate())} ${p(bj.getUTCHours())}:${p(bj.getUTCMinutes())}:${p(bj.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||
{Icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-foreground">{value}</div>
|
||||
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 简易柱状图(纯 SVG,amber 金暖白)
|
||||
function TokenBarChart({ data }: { data: AdminDailyStat[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="text-sm text-muted-foreground py-8 text-center">暂无近 7 日 Token 数据</div>;
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.tokens), 1);
|
||||
const width = 560;
|
||||
const height = 180;
|
||||
const gap = 16;
|
||||
const barW = (width - gap * (data.length - 1)) / data.length;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto">
|
||||
{data.map((d, i) => {
|
||||
const h = Math.max((d.tokens / max) * (height - 30), 2);
|
||||
const x = i * (barW + gap);
|
||||
const y = height - h - 20;
|
||||
return (
|
||||
<g key={d.date}>
|
||||
<rect x={x} y={y} width={barW} height={h} rx={4} fill="var(--primary)" opacity={0.85} />
|
||||
<text x={x + barW / 2} y={height - 6} textAnchor="middle" fontSize="10" fill="var(--muted-foreground)">
|
||||
{d.date.slice(5)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniDonut({ enabled, total }: { enabled: number; total: number }) {
|
||||
const pct = total > 0 ? enabled / total : 0;
|
||||
const r = 26;
|
||||
const c = 2 * Math.PI * r;
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" className="w-16 h-16">
|
||||
<circle cx="36" cy="36" r={r} fill="none" stroke="var(--border)" strokeWidth="8" />
|
||||
<circle
|
||||
cx="36"
|
||||
cy="36"
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth="8"
|
||||
strokeDasharray={`${c * pct} ${c}`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 36 36)"
|
||||
/>
|
||||
<text x="36" y="40" textAnchor="middle" fontSize="14" fontWeight="700" fill="var(--foreground)">
|
||||
{Math.round(pct * 100)}%
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminDashboard() {
|
||||
const { data: user } = useCurrentUser();
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ['admin', 'dashboard', 'stats'],
|
||||
queryFn: adminApi.getStats,
|
||||
});
|
||||
const dailyQuery = useQuery({
|
||||
queryKey: ['admin', 'dashboard', 'daily-stats'],
|
||||
queryFn: adminApi.getDailyStats,
|
||||
});
|
||||
|
||||
const stats = statsQuery.data;
|
||||
const daily = dailyQuery.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">数据看板</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
欢迎回来,{user?.username || '管理员'}。平台实时运营概览。
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">服务器时间(北京)</div>
|
||||
<div className="text-sm font-medium text-foreground">{formatBjTime(stats?.server_time)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误态 */}
|
||||
{statsQuery.isError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 text-destructive px-4 py-3 text-sm">
|
||||
看板数据加载失败:{(statsQuery.error as Error)?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计卡片 6 张 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<StatCard icon={<Users size={18} />} label="总用户数" value={formatNumber(stats?.total_users)} sub={`活跃 ${formatNumber(stats?.active_users)}`} />
|
||||
<StatCard icon={<Cpu size={18} />} label="模型总数" value={formatNumber(stats?.total_models)} sub={`启用 ${formatNumber(stats?.enabled_models)}`} />
|
||||
<StatCard icon={<MessageSquare size={18} />} label="对话总数" value={formatNumber(stats?.total_conversations)} sub={`今日 ${formatNumber(stats?.today_conversations)}`} />
|
||||
<StatCard icon={<BookOpen size={18} />} label="知识库" value={formatNumber(stats?.total_knowledge_bases)} sub={`文档 ${formatNumber(stats?.total_documents)}`} />
|
||||
<StatCard icon={<Coins size={18} />} label="累计 Token" value={formatNumber(stats?.total_tokens)} sub={`今日 ${formatNumber(stats?.today_tokens)}`} />
|
||||
<StatCard icon={<Activity size={18} />} label="今日订单" value={formatNumber(stats?.today_orders)} sub={`订阅 ${formatNumber(stats?.today_subscription_orders)}`} />
|
||||
</div>
|
||||
|
||||
{/* 图表区 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<Card className="border-border lg:col-span-2">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp size={18} className="text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">近 7 日 Token 消耗</h2>
|
||||
</div>
|
||||
{dailyQuery.isLoading ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">加载中…</div>
|
||||
) : (
|
||||
<TokenBarChart data={daily} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border">
|
||||
<CardContent className="p-5">
|
||||
<h2 className="text-base font-semibold text-foreground mb-4">模型启用率</h2>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<MiniDonut enabled={stats?.enabled_models ?? 0} total={stats?.total_models ?? 0} />
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
启用 {formatNumber(stats?.enabled_models)} / 共 {formatNumber(stats?.total_models)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCurrentUser } from '@/hooks/useApi';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Boxes,
|
||||
Crown,
|
||||
ScrollText,
|
||||
Zap,
|
||||
Bot,
|
||||
Puzzle,
|
||||
BookOpen,
|
||||
FileBox,
|
||||
HardDrive,
|
||||
Workflow,
|
||||
Code2,
|
||||
AppWindow,
|
||||
TerminalSquare,
|
||||
Share2,
|
||||
Megaphone,
|
||||
ShoppingBag,
|
||||
Receipt,
|
||||
Building2,
|
||||
BarChart3,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AdminNavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
implemented?: boolean; // true = 真实数据页;false = ComingSoon 占位
|
||||
}
|
||||
|
||||
interface AdminNavGroup {
|
||||
title: string;
|
||||
items: AdminNavItem[];
|
||||
}
|
||||
|
||||
// 导航结构对照旧 admin(AdminLayout.tsx 6 分组 33 项),
|
||||
// 初期仅 Dashboard 为真实数据页,其余以 ComingSoon 占位,保持导航不出现断崖。
|
||||
const adminNavGroups: AdminNavGroup[] = [
|
||||
{
|
||||
title: '概览',
|
||||
items: [
|
||||
{ path: '/admin', label: '数据看板', icon: <LayoutDashboard size={20} />, implemented: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '用户与权限',
|
||||
items: [
|
||||
{ path: '/admin/users', label: '用户管理', icon: <Users size={20} /> },
|
||||
{ path: '/admin/roles', label: '角色权限', icon: <ShieldCheck size={20} /> },
|
||||
{ path: '/admin/tenant', label: '租户管理', icon: <Building2 size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '模型与资源',
|
||||
items: [
|
||||
{ path: '/admin/models', label: '模型管理', icon: <Cpu size={20} /> },
|
||||
{ path: '/admin/knowledge', label: '知识库', icon: <BookOpen size={20} /> },
|
||||
{ path: '/admin/assets', label: '素材管理', icon: <FileBox size={20} /> },
|
||||
{ path: '/admin/storage', label: '存储管理', icon: <HardDrive size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '智能体与工具',
|
||||
items: [
|
||||
{ path: '/admin/agents', label: '智能体', icon: <Bot size={20} /> },
|
||||
{ path: '/admin/mcp', label: 'MCP 工具', icon: <Puzzle size={20} /> },
|
||||
{ path: '/admin/mcp-market', label: 'MCP 市场', icon: <Puzzle size={20} /> },
|
||||
{ path: '/admin/skills', label: '技能管理', icon: <Zap size={20} /> },
|
||||
{ path: '/admin/workflow', label: '工作流', icon: <Workflow size={20} /> },
|
||||
{ path: '/admin/automation', label: '自动化', icon: <TerminalSquare size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '应用与分发',
|
||||
items: [
|
||||
{ path: '/admin/apps', label: '应用管理', icon: <AppWindow size={20} /> },
|
||||
{ path: '/admin/code-exec', label: '代码执行', icon: <Code2 size={20} /> },
|
||||
{ path: '/admin/distributions', label: '分发渠道', icon: <Share2 size={20} /> },
|
||||
{ path: '/admin/ads', label: '广告管理', icon: <Megaphone size={20} /> },
|
||||
{ path: '/admin/market', label: '应用市场', icon: <ShoppingBag size={20} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '运营与计费',
|
||||
items: [
|
||||
{ path: '/admin/payment', label: '支付配置', icon: <CreditCard size={20} /> },
|
||||
{ path: '/admin/billing', label: '账单计费', icon: <Receipt size={20} /> },
|
||||
{ path: '/admin/membership', label: '会员体系', icon: <Crown size={20} /> },
|
||||
{ path: '/admin/services', label: '增值服务', icon: <Boxes size={20} /> },
|
||||
{ path: '/admin/logs', label: '日志审计', icon: <ScrollText size={20} /> },
|
||||
{ path: '/admin/developer', label: '开发者', icon: <Code2 size={20} /> },
|
||||
{ path: '/admin/analytics', label: '数据分析', icon: <BarChart3 size={20} /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { data: user } = useCurrentUser();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex">
|
||||
{/* 侧边栏 */}
|
||||
<aside className={cn(
|
||||
'fixed left-0 top-0 z-40 h-screen border-r border-border bg-white dark:bg-gray-950 transition-all duration-300 flex flex-col',
|
||||
collapsed ? 'w-[68px]' : 'w-[248px]'
|
||||
)}>
|
||||
{/* Logo + 访问前台 */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-border">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">七</span>
|
||||
</div>
|
||||
<span className="text-base font-bold text-foreground">七虎AI · 后台</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<div className="flex-1 overflow-y-auto py-4 px-2">
|
||||
{adminNavGroups.map((group, i) => (
|
||||
<div key={i} className="mb-4">
|
||||
{!collapsed && (
|
||||
<div className="px-3 mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{group.title}
|
||||
</div>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === '/admin'}
|
||||
className={({ isActive }) => cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部:访问前台 + 用户信息 + 退出 */}
|
||||
<div className="border-t border-border py-2 px-2 space-y-1">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors w-full"
|
||||
title={collapsed ? '访问前台' : undefined}
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
{!collapsed && <span>访问前台</span>}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{(user?.username || 'A').slice(0, 1).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">{user?.username || '管理员'}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{user?.role || 'admin'}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground transition-colors"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 主区域 */}
|
||||
<div className={cn('flex-1 flex flex-col min-h-screen', collapsed ? 'ml-[68px]' : 'ml-[248px]')}>
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { AdminComingSoon } from './AdminComingSoon';
|
||||
import {
|
||||
Users,
|
||||
ShieldCheck,
|
||||
Building2,
|
||||
Cpu,
|
||||
BookOpen,
|
||||
FileBox,
|
||||
HardDrive,
|
||||
Bot,
|
||||
Puzzle,
|
||||
Zap,
|
||||
Workflow,
|
||||
TerminalSquare,
|
||||
AppWindow,
|
||||
Code2,
|
||||
Share2,
|
||||
Megaphone,
|
||||
ShoppingBag,
|
||||
CreditCard,
|
||||
Receipt,
|
||||
Crown,
|
||||
Boxes,
|
||||
ScrollText,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* admin 后台占位页集中导出。
|
||||
* 旧 AdminLayout 共 33 个二级模块,初期除 Dashboard(真实数据页)外,
|
||||
* 其余 32 个模块统一用 AdminComingSoon 占位,待后续阶段逐个迁移真实数据。
|
||||
* 每个导出对应一条侧边栏导航路由。
|
||||
*/
|
||||
|
||||
export function UsersPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="用户管理"
|
||||
icon={Users}
|
||||
description="管理全平台注册用户,查看活跃度、封禁状态与归属租户。"
|
||||
features={['用户列表与搜索', '角色与状态管理', '登录日志审计']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RolesPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="角色权限"
|
||||
icon={ShieldCheck}
|
||||
description="配置 RBAC 角色与细粒度权限策略,管控后台访问边界。"
|
||||
features={['角色 CRUD', '权限点分配', '资源级授权']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TenantPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="租户管理"
|
||||
icon={Building2}
|
||||
description="管理多租户隔离、配额与租户级配置。"
|
||||
features={['租户列表', '配额调整', '隔离策略']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelsPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="模型管理"
|
||||
icon={Cpu}
|
||||
description="接入与编排 LLM 供应商、模型上下线与计费路由。"
|
||||
features={['供应商配置', '模型启停', '路由权重']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function KnowledgeAdminPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="知识库管理"
|
||||
icon={BookOpen}
|
||||
description="平台级知识库与向量检索策略管理。"
|
||||
features={['知识库列表', '检索调优', '容量监控']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetsPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="素材管理"
|
||||
icon={FileBox}
|
||||
description="管理上传的文档、图片与多模态素材资源。"
|
||||
features={['素材库', '分类标签', '占用统计']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoragePage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="存储管理"
|
||||
icon={HardDrive}
|
||||
description="对象存储桶、配额与冷热分层策略。"
|
||||
features={['存储桶', '配额告警', '生命周期']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsAdminPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="智能体管理"
|
||||
icon={Bot}
|
||||
description="平台级 Agent 编排模板与市场审核。"
|
||||
features={['Agent 模板', '发布审核', '调用统计']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function McpPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="MCP 工具"
|
||||
icon={Puzzle}
|
||||
description="管理 Model Context Protocol 工具接入与连通性。"
|
||||
features={['工具配置', '连通测试', '权限治理']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function McpMarketPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="MCP 市场"
|
||||
icon={Puzzle}
|
||||
description="官方与社区 MCP 应用市场运营。"
|
||||
features={['上架审核', '分类运营', '下载统计']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillsAdminPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="技能管理"
|
||||
icon={Zap}
|
||||
description="AI 自进化技能库的状态与质量治理。"
|
||||
features={['技能状态', '质量评分', '冻结/解冻']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowAdminPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="工作流管理"
|
||||
icon={Workflow}
|
||||
description="可视化 DAG 自动化流程的模板与运行监控。"
|
||||
features={['流程模板', '运行记录', '失败重试']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutomationPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="自动化"
|
||||
icon={TerminalSquare}
|
||||
description="定时任务、触发器与事件驱动自动化。"
|
||||
features={['任务编排', '触发器', '执行日志']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppsAdminPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="应用管理"
|
||||
icon={AppWindow}
|
||||
description="平台内置应用的上架、配置与下线。"
|
||||
features={['应用列表', '配置项', '灰度发布']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeExecPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="代码执行"
|
||||
icon={Code2}
|
||||
description="沙箱代码执行环境的安全与配额管控。"
|
||||
features={['沙箱配置', '资源限额', '审计日志']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DistributionsPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="分发渠道"
|
||||
icon={Share2}
|
||||
description="管理多渠道分发与推广链接。"
|
||||
features={['渠道列表', '推广码', '转化统计']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdsPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="广告管理"
|
||||
icon={Megaphone}
|
||||
description="运营广告位与投放策略。"
|
||||
features={['广告位', '创意管理', '曝光统计']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketAdminPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="应用市场"
|
||||
icon={ShoppingBag}
|
||||
description="技能/应用市场的审核、分类与运营。"
|
||||
features={['上架审核', '分类运营', '交易对账']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="支付配置"
|
||||
icon={CreditCard}
|
||||
description="支付网关、回调与对账配置。"
|
||||
features={['网关配置', '回调校验', '对账文件']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BillingPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="账单计费"
|
||||
icon={Receipt}
|
||||
description="用量计费、账单生成与发票管理。"
|
||||
features={['账单列表', '用量明细', '发票管理']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MembershipPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="会员体系"
|
||||
icon={Crown}
|
||||
description="会员等级、权益与续费策略。"
|
||||
features={['等级配置', '权益包', '续费统计']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="增值服务"
|
||||
icon={Boxes}
|
||||
description="管理付费增值服务与套餐。"
|
||||
features={['服务列表', '套餐配置', '售卖统计']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="日志审计"
|
||||
icon={ScrollText}
|
||||
description="操作日志与审计轨迹,支持 CSV 导出。"
|
||||
features={['操作日志', '审计追踪', '导出']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeveloperPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="开发者"
|
||||
icon={Code2}
|
||||
description="开放 API、密钥与开发者生态管理。"
|
||||
features={['API 密钥', 'SDK 下载', '调用配额']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalyticsPage() {
|
||||
return (
|
||||
<AdminComingSoon
|
||||
title="数据分析"
|
||||
icon={BarChart3}
|
||||
description="全平台运营数据看板与多维分析。"
|
||||
features={['留存分析', '漏斗分析', '自定义报表']}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
AppWindow,
|
||||
Plus,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Play,
|
||||
Globe,
|
||||
Monitor,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useMyApps } from '@/hooks/useApi';
|
||||
import type { MarketApp } from '@/api/client';
|
||||
|
||||
export function AppServices() {
|
||||
const { data: rawApps, isLoading } = useMyApps();
|
||||
const apps: MarketApp[] = Array.isArray(rawApps) ? rawApps : (((rawApps as any)?.data ?? (rawApps as any)?.items) ?? []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">应用中心</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理第三方 AI 应用</p>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 创建应用
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<AppWindow size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-1">暂无应用</p>
|
||||
<p className="text-sm">去应用市场浏览并安装应用</p>
|
||||
</div>
|
||||
) : (
|
||||
apps.map((app: MarketApp) => (
|
||||
<Card key={app.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
{app.type === 'standalone' ? (
|
||||
<Globe size={20} className="text-primary" />
|
||||
) : (
|
||||
<Monitor size={20} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={app.status === 'enabled' ? 'success' : 'secondary'}>
|
||||
{app.status === 'enabled' ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{app.type === 'standalone' ? '独立应用' : '嵌入式'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-foreground mb-2">{app.name}</h3>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">{app.description}</p>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Settings size={14} /> 配置
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
variant={app.status === 'enabled' ? 'default' : 'outline'}
|
||||
>
|
||||
{app.status === 'enabled' ? (
|
||||
<><ExternalLink size={14} /> 打开</>
|
||||
) : (
|
||||
<><Play size={14} /> 启用</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Create New */}
|
||||
<Card className="border-dashed hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[220px]">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
|
||||
<Plus size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">创建新应用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Send,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
Copy,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useConversations,
|
||||
useMessages,
|
||||
useCreateConversation,
|
||||
useSendMessage,
|
||||
useDeleteConversation,
|
||||
} from '@/hooks/useApi';
|
||||
import type { Conversation, ChatMessage } from '@/api/client';
|
||||
|
||||
export function ChatPage() {
|
||||
const [selectedConv, setSelectedConv] = useState<string | null>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
// API hooks
|
||||
const { data: conversations = [], isLoading: convsLoading } = useConversations();
|
||||
const { data: messages = [], isLoading: msgsLoading } = useMessages(selectedConv);
|
||||
const createConv = useCreateConversation();
|
||||
const sendMessage = useSendMessage(selectedConv || '');
|
||||
const deleteConv = useDeleteConversation();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const conv = await createConv.mutateAsync({ title: '新对话' });
|
||||
setSelectedConv(conv.id);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !selectedConv) return;
|
||||
const content = input;
|
||||
setInput('');
|
||||
setStreamingContent('');
|
||||
|
||||
try {
|
||||
// Try streaming first
|
||||
const stream = chatApi.streamMessage(selectedConv, { content });
|
||||
let accumulated = '';
|
||||
for await (const chunk of stream) {
|
||||
accumulated += chunk;
|
||||
setStreamingContent(accumulated);
|
||||
}
|
||||
setStreamingContent('');
|
||||
// Refresh messages
|
||||
} catch {
|
||||
// Fallback to regular send
|
||||
await sendMessage.mutateAsync({ content });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteConv.mutateAsync(id);
|
||||
if (selectedConv === id) setSelectedConv(null);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] gap-4">
|
||||
{/* Conversation List */}
|
||||
<div className="w-72 flex-shrink-0 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索对话..."
|
||||
className="h-9 w-full rounded-lg bg-muted pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mx-3 mt-3 gap-2"
|
||||
variant="outline"
|
||||
onClick={handleNewChat}
|
||||
disabled={createConv.isPending}
|
||||
>
|
||||
<Plus size={16} /> 新建对话
|
||||
</Button>
|
||||
|
||||
<ScrollArea className="flex-1 px-2 py-2">
|
||||
<div className="space-y-1">
|
||||
{convsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv: Conversation) => (
|
||||
<div key={conv.id} className="group relative">
|
||||
<button
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
|
||||
selectedConv === conv.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{conv.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 ml-5">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{conv.model || 'Auto'}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(conv.id); }}
|
||||
className="absolute right-2 top-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
|
||||
>
|
||||
<Trash2 size={12} className="text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{!selectedConv ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">开始与 AI 对话吧</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天
|
||||
</p>
|
||||
<Button className="mt-4 gap-2" onClick={handleNewChat}>
|
||||
<Plus size={16} /> 新建对话
|
||||
</Button>
|
||||
</div>
|
||||
) : msgsLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{messages.map((msg: ChatMessage) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
}`}>
|
||||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<Copy size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<ThumbsUp size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<ThumbsDown size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<RotateCcw size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{streamingContent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="max-w-[80%] rounded-xl px-4 py-3 bg-muted text-foreground">
|
||||
<div className="text-sm whitespace-pre-wrap">{streamingContent}</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.2s]" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||
placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'}
|
||||
className="flex-1"
|
||||
disabled={!selectedConv || sendMessage.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !selectedConv || sendMessage.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{sendMessage.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles size={12} /> Auto(自动匹配)
|
||||
</div>
|
||||
<button className="p-1 hover:bg-muted rounded transition-colors">
|
||||
<Settings size={16} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Import for streaming
|
||||
import { chatApi } from '@/api/client';
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Coins,
|
||||
TrendingDown,
|
||||
Boxes,
|
||||
Clock,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useTokenOverview,
|
||||
useTokenRankings,
|
||||
useTokenBalance,
|
||||
useTokenLogs,
|
||||
} from '@/hooks/useApi';
|
||||
import type { TokenLogItem } from '@/api/client';
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
accent,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${accent}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-foreground tabular-nums">{value}</div>
|
||||
{hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function ModelRankingBar({ name, total, max }: { name: string; total: number; max: number }) {
|
||||
const pct = max > 0 ? Math.round((total / max) * 100) : 0;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground truncate pr-2">{name}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{formatNumber(total)}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className="h-full rounded-full bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 15;
|
||||
|
||||
const { data: overview, isLoading: ovLoading } = useTokenOverview();
|
||||
const { data: rankings } = useTokenRankings();
|
||||
const { data: balance } = useTokenBalance();
|
||||
const { data: logs, isLoading: logsLoading, isFetching: logsFetching } = useTokenLogs({
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
|
||||
const maxModelTokens = useMemo(
|
||||
() => Math.max(0, ...(rankings?.model_rankings ?? []).map((m) => m.total_tokens)),
|
||||
[rankings]
|
||||
);
|
||||
|
||||
const total = logs?.total ?? 0;
|
||||
const items: TokenLogItem[] = logs?.items ?? [];
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const handleReset = () => {
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Token 驾驶舱</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
实时掌握 Token 消耗、余额与调用明细
|
||||
</p>
|
||||
</div>
|
||||
{overview?.server_time && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock size={12} /> 北京时间 {formatTime(overview.server_time)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Coins}
|
||||
label="当前余额"
|
||||
value={ovLoading ? '—' : `${formatNumber(overview?.balance ?? 0)}`}
|
||||
hint="tokens 可用额度"
|
||||
accent="bg-primary/10 text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingDown}
|
||||
label="累计消耗"
|
||||
value={ovLoading ? '—' : formatNumber(overview?.total_used ?? 0)}
|
||||
hint="历史总 token 用量"
|
||||
accent="bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Boxes}
|
||||
label="今日活跃模型"
|
||||
value={ovLoading ? '—' : `${overview?.today_apps ?? 0}`}
|
||||
hint="当日有调用的模型数"
|
||||
accent="bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="调用记录"
|
||||
value={logsLoading ? '—' : formatNumber(total)}
|
||||
hint="筛选范围内总条数"
|
||||
accent="bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rankings + balance side panel */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>模型消耗排行</CardTitle>
|
||||
<CardDescription>按累计 token 消耗排序的模型分布</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!rankings || rankings.model_rankings.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无消耗数据
|
||||
</div>
|
||||
) : (
|
||||
rankings.model_rankings.slice(0, 6).map((m) => (
|
||||
<ModelRankingBar
|
||||
key={m.model_id}
|
||||
name={m.model_name}
|
||||
total={m.total_tokens}
|
||||
max={maxModelTokens}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>余额概览</CardTitle>
|
||||
<CardDescription>账户 token 余额状态</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">当前余额</p>
|
||||
<p className="text-3xl font-semibold text-foreground tabular-nums mt-1">
|
||||
{balance ? formatNumber(balance.balance) : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">tokens</p>
|
||||
</div>
|
||||
<div className="h-px bg-border" />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">最近更新</span>
|
||||
<span className="text-foreground">{formatTime(balance?.updated_at ?? null)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">累计消耗</span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{formatNumber(overview?.total_used ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
{rankings && rankings.top_apps.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-border" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">Top 应用(按消耗)</p>
|
||||
<div className="space-y-1">
|
||||
{rankings.top_apps.map((app, i) => (
|
||||
<div
|
||||
key={app.conversation_id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-foreground truncate pr-2">
|
||||
#{i + 1} {app.conversation_id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatNumber(app.total_tokens)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Logs table */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>调用明细</CardTitle>
|
||||
<CardDescription>Token 消耗逐条记录,支持日期筛选与分页</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">开始日期</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => {
|
||||
setStartDate(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">结束日期</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => {
|
||||
setEndDate(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" className="gap-1" onClick={handleReset}>
|
||||
<Filter size={14} /> 重置
|
||||
</Button>
|
||||
{logsFetching && <Loader2 size={16} className="animate-spin text-primary" />}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2.5">时间</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">模型</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Prompt</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Completion</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logsLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-10 text-muted-foreground">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-10 text-muted-foreground">
|
||||
暂无调用记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="border-t border-border hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
|
||||
{formatTime(log.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-foreground">{log.model_name}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-muted-foreground">
|
||||
{formatNumber(log.prompt_tokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-muted-foreground">
|
||||
{formatNumber(log.completion_tokens)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums font-medium text-foreground">
|
||||
{formatNumber(log.total_tokens)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
第 {page} / {totalPages} 页 · 共 {formatNumber(total)} 条
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={page <= 1 || logsFetching}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft size={14} /> 上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={page >= totalPages || logsFetching}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页 <ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
QrCode,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useImBotStatus, useDisconnectBot } from '@/hooks/useApi';
|
||||
|
||||
const otherChannels = [
|
||||
{ id: '2', name: '企业微信', icon: '🏢', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
{ id: '3', name: '飞书', icon: '📨', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
{ id: '4', name: 'QQ', icon: '🐧', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
];
|
||||
|
||||
export function ImBotHub() {
|
||||
const { data: wechatStatus, isLoading } = useImBotStatus();
|
||||
const disconnectBot = useDisconnectBot();
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!confirm('确定断开微信连接?')) return;
|
||||
try {
|
||||
await disconnectBot.mutateAsync();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">渠道管理</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">配置和管理您的 IM 渠道</p>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 添加渠道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* WeChat Bot - Real Data */}
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-2xl">
|
||||
💬
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">个人微信</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{wechatStatus?.ilink_bot_id || '未配置'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={wechatStatus?.status === 'connected' ? 'success' : 'secondary'}>
|
||||
{isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : wechatStatus?.status === 'connected' ? (
|
||||
<span className="flex items-center gap-1"><Wifi size={12} /> 已连接</span>
|
||||
) : wechatStatus?.status === 'expired' ? (
|
||||
<span className="flex items-center gap-1 text-amber-600"><AlertTriangle size={12} /> 已过期</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{wechatStatus?.status === 'connected' && (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={14} /> {wechatStatus.message_count || 0} 条消息
|
||||
</span>
|
||||
<span>
|
||||
连接于 {new Date(wechatStatus.connected_at).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wechatStatus?.status === 'expired' && (
|
||||
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm mb-4">
|
||||
会话已过期,请重新扫码连接
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{wechatStatus?.status === 'connected' ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Settings size={14} /> 管理
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ExternalLink size={14} /> 查看对话
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnectBot.isPending}
|
||||
>
|
||||
{disconnectBot.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
断开连接
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" className="gap-1">
|
||||
<QrCode size={14} /> 扫码绑定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Other Channels - Mock */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
{otherChannels.map((channel) => (
|
||||
<Card key={channel.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center text-2xl">
|
||||
{channel.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{channel.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">未配置</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="gap-1">
|
||||
<QrCode size={14} /> 扫码绑定
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Upload,
|
||||
FileText,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
useKnowledgeBases,
|
||||
useCreateKnowledgeBase,
|
||||
useDeleteKnowledgeBase,
|
||||
} from '@/hooks/useApi';
|
||||
import type { KnowledgeBase } from '@/api/client';
|
||||
|
||||
export function KnowledgePage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
const { data: bases = [], isLoading } = useKnowledgeBases();
|
||||
const createBase = useCreateKnowledgeBase();
|
||||
const deleteBase = useDeleteKnowledgeBase();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await createBase.mutateAsync({ name: newName, description: newDesc });
|
||||
setShowCreateDialog(false);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除此知识库?')) return;
|
||||
try {
|
||||
await deleteBase.mutateAsync(id);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="在所有知识库中检索问题..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
支持跨库检索 · 向量相似度排序 · 来源溯源
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="all">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部知识库</TabsTrigger>
|
||||
<TabsTrigger value="recent">最近使用</TabsTrigger>
|
||||
</TabsList>
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 新建知识库
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建知识库</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
placeholder="知识库名称"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
placeholder="简要描述(可选)"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim() || createBase.isPending}
|
||||
>
|
||||
{createBase.isPending ? (
|
||||
<><Loader2 size={16} className="animate-spin" /> 创建中...</>
|
||||
) : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="mt-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
bases.map((kb: KnowledgeBase) => (
|
||||
<Card key={kb.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Database size={20} className="text-primary" />
|
||||
</div>
|
||||
<Badge variant={kb.status === 'active' ? 'success' : 'secondary'}>
|
||||
{kb.status === 'active' ? '运行中' : kb.status === 'building' ? '构建中' : '异常'}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground mb-1">{kb.name}</h3>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText size={14} /> {kb.document_count} 文档
|
||||
</span>
|
||||
</div>
|
||||
{kb.description && (
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">{kb.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
更新于 {new Date(kb.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
|
||||
<Upload size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
|
||||
<RefreshCw size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(kb.id)}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
disabled={deleteBase.isPending}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Add New Card */}
|
||||
<Card
|
||||
className="border-dashed hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
>
|
||||
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[200px]">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
|
||||
<Plus size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">新建知识库</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Bot,
|
||||
Puzzle,
|
||||
BookOpen,
|
||||
AppWindow,
|
||||
TrendingUp,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useDashboardStats, useMarketApps, useCurrentUser } from '@/hooks/useApi';
|
||||
|
||||
const quickEntries = [
|
||||
{ icon: <MessageSquare size={24} />, label: 'AI 聊天', desc: 'SSE 流式对话,支持代码块和 Markdown', path: '/chat', color: 'bg-blue-50 text-blue-600' },
|
||||
{ icon: <Zap size={24} />, label: '技能市场', desc: '浏览、导入、发布 Skills 技能', path: '/market', color: 'bg-amber-50 text-amber-600' },
|
||||
{ icon: <Bot size={24} />, label: 'Agent 智能体', desc: 'Dify / N8N / Coze 多源导入', path: '/agents', color: 'bg-green-50 text-green-600' },
|
||||
{ icon: <Puzzle size={24} />, label: 'MCP 工具', desc: 'Model Context Protocol 工具集成', path: '/mcp', color: 'bg-purple-50 text-purple-600' },
|
||||
{ icon: <BookOpen size={24} />, label: '知识库', desc: '文档管理 + RAG 向量检索', path: '/knowledge', color: 'bg-cyan-50 text-cyan-600' },
|
||||
{ icon: <AppWindow size={24} />, label: '应用中心', desc: '第三方 AI 应用接入与管理', path: '/apps', color: 'bg-pink-50 text-pink-600' },
|
||||
];
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
export function LandingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: stats, isLoading: statsLoading } = useDashboardStats();
|
||||
const { data: rawApps, isLoading: appsLoading } = useMarketApps();
|
||||
// Defensive: backend may wrap the list or return an error object; coerce to array
|
||||
// so `.slice()` / `.filter()` below never crash the whole page.
|
||||
const apps: any[] = Array.isArray(rawApps) ? rawApps : (rawApps?.data ?? rawApps?.items ?? []);
|
||||
const { data: user } = useCurrentUser();
|
||||
|
||||
const displayStats = [
|
||||
{ label: '活跃用户', value: stats?.active_users?.toLocaleString() || '---', change: stats?.user_change, trend: 'up' as const },
|
||||
{ label: '今日对话', value: stats?.today_conversations?.toLocaleString() || '---', change: stats?.conv_change, trend: 'up' as const },
|
||||
{ label: '技能总数', value: stats?.skill_count?.toString() || '---', change: undefined, trend: 'up' as const },
|
||||
{ label: '交易金额', value: stats?.revenue ? `¥${stats.revenue.toLocaleString()}` : '---', change: undefined, trend: 'up' as const },
|
||||
];
|
||||
|
||||
// Derive hot skills and agents from apps
|
||||
const hotSkills = apps.slice(0, 5).map((app: { id: string; name: string; downloads: number }) => ({
|
||||
name: app.name,
|
||||
downloads: app.downloads >= 1000 ? `${(app.downloads / 1000).toFixed(1)}k` : app.downloads.toString(),
|
||||
}));
|
||||
|
||||
const hotAgents = apps.filter((a: { category: string }) => a.category === 'agent').slice(0, 5).map((app: { id: string; name: string; downloads: number }) => ({
|
||||
name: app.name,
|
||||
downloads: app.downloads >= 1000 ? `${(app.downloads / 1000).toFixed(1)}k` : app.downloads.toString(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border bg-white/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center shadow-sm">
|
||||
<span className="text-white font-bold text-base">七</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-bold text-foreground">七虎AI</span>
|
||||
<span className="text-xs text-muted-foreground ml-2 hidden sm:inline">AI PaaS 平台</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6">
|
||||
<button onClick={() => navigate('/market')} className="text-sm text-muted-foreground hover:text-foreground transition-colors">技能市场</button>
|
||||
<button onClick={() => navigate('/agents')} className="text-sm text-muted-foreground hover:text-foreground transition-colors">Agent</button>
|
||||
<button onClick={() => navigate('/chat')} className="text-sm text-muted-foreground hover:text-foreground transition-colors">AI 聊天</button>
|
||||
<button
|
||||
onClick={() => user ? navigate('/chat') : navigate('/login')}
|
||||
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{user ? user.username : '登录 / 注册'}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"
|
||||
>
|
||||
{displayStats.map((stat, i) => (
|
||||
<motion.div key={i} variants={item}>
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
{statsLoading ? (
|
||||
<Loader2 size={20} className="animate-spin text-primary" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-foreground">{stat.value}</p>
|
||||
)}
|
||||
{stat.change && (
|
||||
<span className="text-xs text-green-600 font-medium flex items-center gap-0.5">
|
||||
<TrendingUp size={12} /> {stat.change}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Entry */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">快捷入口</h2>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{quickEntries.map((entry, i) => (
|
||||
<motion.div key={i} variants={item}>
|
||||
<Card
|
||||
className="hover:shadow-md hover:border-primary/20 transition-all cursor-pointer group"
|
||||
onClick={() => navigate(entry.path)}
|
||||
>
|
||||
<CardContent className="p-5 flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center shrink-0 ${entry.color}`}>
|
||||
{entry.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{entry.label}
|
||||
</h3>
|
||||
<ArrowRight size={16} className="text-muted-foreground group-hover:text-primary transition-colors opacity-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{entry.desc}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Hot Rankings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<motion.div variants={container} initial="hidden" animate="show">
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2">
|
||||
<Zap size={18} className="text-amber-500" /> 热门技能
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => navigate('/market')}
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
更多 <ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{appsLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : hotSkills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">暂无数据</p>
|
||||
) : (
|
||||
hotSkills.map((skill, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={item}
|
||||
className="flex items-center justify-between py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-5 h-5 rounded-md bg-primary/10 text-primary text-xs font-bold flex items-center justify-center">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-sm text-foreground">{skill.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{skill.downloads}</span>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={container} initial="hidden" animate="show">
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2">
|
||||
<Bot size={18} className="text-green-500" /> 热门 Agent
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => navigate('/agents')}
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
更多 <ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{appsLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : hotAgents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">暂无数据</p>
|
||||
) : (
|
||||
hotAgents.map((agent, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={item}
|
||||
className="flex items-center justify-between py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-5 h-5 rounded-md bg-green-50 text-green-600 text-xs font-bold flex items-center justify-center">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-sm text-foreground">{agent.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{agent.downloads}</span>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border mt-12 py-8">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center text-sm text-muted-foreground">
|
||||
© 2026 七虎AI · 养龙虾与 AI 能力探索
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Mail, Smartphone, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useLogin } from '@/hooks/useApi';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const loginMutation = useLogin();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleLogin = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await loginMutation.mutateAsync({ username: email, password });
|
||||
navigate('/chat');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-amber-100/60 dark:from-[#1C1814] dark:via-[#221C15] dark:to-[#2A2118] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<span className="text-white font-bold text-2xl">七</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">七虎AI</h1>
|
||||
<p className="text-muted-foreground mt-1">养龙虾与 AI 能力探索</p>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0">
|
||||
<CardContent className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="email" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="email" className="gap-2">
|
||||
<Mail size={16} /> 邮箱登录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="phone" className="gap-2">
|
||||
<Smartphone size={16} /> 手机登录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="email">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱地址</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full gap-2" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? (
|
||||
<><Loader2 size={16} className="animate-spin" /> 登录中...</>
|
||||
) : (
|
||||
<>登录 <ArrowRight size={16} /></>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="phone">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号码</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">验证码</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="code"
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" type="button" className="whitespace-nowrap">
|
||||
发送验证码
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full gap-2">
|
||||
登录 <ArrowRight size={16} />
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<a href="#" className="text-primary hover:underline">忘记密码?</a>
|
||||
<span className="mx-2">·</span>
|
||||
<span>还没有账号?</span>
|
||||
<a href="#" className="text-primary hover:underline ml-1">立即注册</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Crown,
|
||||
Coins,
|
||||
History,
|
||||
KeyRound,
|
||||
ShoppingBag,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Phone,
|
||||
CalendarDays,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useProfile,
|
||||
useProfileBalance,
|
||||
useProfileMembership,
|
||||
useProfileLoginLogs,
|
||||
useProfileApiKeys,
|
||||
useProfilePurchasedApps,
|
||||
useUpdateProfile,
|
||||
useUpdateProfilePassword,
|
||||
useCreateApiKey,
|
||||
useUpdateApiKey,
|
||||
} from '@/hooks/useApi';
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
accent,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${accent}`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-foreground tabular-nums">{value}</div>
|
||||
{hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Icon size={15} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground w-16 shrink-0">{label}</span>
|
||||
<span className="text-foreground truncate">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 改资料:内联表单,提交后 invalidate profile 刷新只读数据
|
||||
function EditProfileSection({ profile }: { profile?: { username: string; email: string; phone: string } }) {
|
||||
const updateProfile = useUpdateProfile();
|
||||
const [username, setUsername] = useState(profile?.username ?? '');
|
||||
const [email, setEmail] = useState(profile?.email ?? '');
|
||||
const [phone, setPhone] = useState(profile?.phone ?? '');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setUsername(profile?.username ?? '');
|
||||
setEmail(profile?.email ?? '');
|
||||
setPhone(profile?.phone ?? '');
|
||||
setError(null);
|
||||
setOk(false);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setOk(false);
|
||||
updateProfile.mutate(
|
||||
{ username, email, phone },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOk(true);
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '更新失败'),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={() => { reset(); setEditing(true); }}>
|
||||
<Pencil size={13} /> 编辑资料
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-3 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pf-username" className="text-xs">用户名</Label>
|
||||
<Input id="pf-username" value={username} onChange={(e) => setUsername(e.target.value)} disabled={updateProfile.isPending} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pf-email" className="text-xs">邮箱</Label>
|
||||
<Input id="pf-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={updateProfile.isPending} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pf-phone" className="text-xs">手机</Label>
|
||||
<Input id="pf-phone" value={phone} onChange={(e) => setPhone(e.target.value)} disabled={updateProfile.isPending} />
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{ok && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<CheckCircle2 size={13} /> 资料已更新
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" className="gap-1" disabled={updateProfile.isPending}>
|
||||
{updateProfile.isPending && <Loader2 size={13} className="animate-spin" />} 保存
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setEditing(false); reset(); }} disabled={updateProfile.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// 改密:独立卡片,含原密码/新密码校验
|
||||
function ChangePasswordSection() {
|
||||
const changePwd = useUpdateProfilePassword();
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setOk(false);
|
||||
if (newPassword.length < 6) {
|
||||
setError('新密码至少 6 位');
|
||||
return;
|
||||
}
|
||||
changePwd.mutate(
|
||||
{ old_password: oldPassword, new_password: newPassword },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOk(true);
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '修改失败'),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound size={16} className="text-muted-foreground" /> 修改密码
|
||||
</CardTitle>
|
||||
<CardDescription>定期更新密码以保障账户安全</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-3 max-w-sm">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pf-old" className="text-xs">原密码</Label>
|
||||
<Input id="pf-old" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} disabled={changePwd.isPending} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pf-new" className="text-xs">新密码(至少 6 位)</Label>
|
||||
<Input id="pf-new" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} disabled={changePwd.isPending} />
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{ok && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<CheckCircle2 size={13} /> 密码修改成功
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" size="sm" className="gap-1" disabled={changePwd.isPending || !oldPassword || !newPassword}>
|
||||
{changePwd.isPending && <Loader2 size={13} className="animate-spin" />} 确认修改
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
const logPageSize = 8;
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
|
||||
const { data: profile, isLoading: pLoading } = useProfile();
|
||||
const { data: balance, isLoading: bLoading } = useProfileBalance();
|
||||
const { data: membership } = useProfileMembership();
|
||||
const { data: loginLogs, isLoading: logsLoading, isFetching: logsFetching } = useProfileLoginLogs(
|
||||
logPage,
|
||||
logPageSize
|
||||
);
|
||||
const { data: apiKeys, isLoading: keysLoading } = useProfileApiKeys();
|
||||
const { data: purchasedApps, isLoading: appsLoading } = useProfilePurchasedApps();
|
||||
const createApiKey = useCreateApiKey();
|
||||
const updateApiKey = useUpdateApiKey();
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
|
||||
const logTotal = loginLogs?.total ?? 0;
|
||||
const logItems = loginLogs?.items ?? [];
|
||||
const logTotalPages = Math.max(1, Math.ceil(logTotal / logPageSize));
|
||||
|
||||
const isExpired = membership?.is_expired ?? false;
|
||||
const hasMembership = membership?.has_membership ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">个人中心</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理账户资料、会员状态、余额与 API 密钥
|
||||
</p>
|
||||
</div>
|
||||
{profile?.created_at && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<CalendarDays size={12} /> 注册于 {formatTime(profile.created_at).split(' ')[0]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile card + membership */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Profile identity */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>账户资料</CardTitle>
|
||||
<CardDescription>当前登录账户的基础信息</CardDescription>
|
||||
</div>
|
||||
<EditProfileSection profile={profile} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xl font-semibold shrink-0">
|
||||
{profile?.username?.[0]?.toUpperCase() ?? '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-base font-medium text-foreground truncate">
|
||||
{pLoading ? '加载中…' : profile?.username ?? '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{profile?.role ?? 'user'} · {profile?.status ?? 'active'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-border" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<InfoRow icon={Mail} label="邮箱" value={profile?.email || '未设置'} />
|
||||
<InfoRow icon={Phone} label="手机" value={profile?.phone || '未设置'} />
|
||||
<InfoRow
|
||||
icon={UserIcon}
|
||||
label="用户ID"
|
||||
value={profile?.id ? profile.id.slice(0, 12) : '—'}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={CalendarDays}
|
||||
label="更新"
|
||||
value={formatTime(profile?.updated_at ?? null).split(' ')[0]}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Membership */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Crown size={16} className="text-amber-500" /> 会员状态
|
||||
</CardTitle>
|
||||
<CardDescription>当前订阅与有效期</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!hasMembership ? (
|
||||
<div className="text-center py-6">
|
||||
<Crown size={28} className="text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">尚未开通会员</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">套餐</span>
|
||||
<Badge
|
||||
variant={isExpired ? 'destructive' : 'default'}
|
||||
className={isExpired ? '' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}
|
||||
>
|
||||
{isExpired ? '已过期' : '生效中'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">计划 ID</span>
|
||||
<span className="text-foreground">{membership?.plan_id ?? '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">生效</span>
|
||||
<span className="text-foreground">{formatTime(membership?.start_at ?? null).split(' ')[0]}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">到期</span>
|
||||
<span className="text-foreground">{formatTime(membership?.expire_at ?? null).split(' ')[0]}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Balance stat row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
icon={Coins}
|
||||
label="Token 余额"
|
||||
value={bLoading ? '—' : formatNumber(balance ?? 0)}
|
||||
hint="当前可用 tokens"
|
||||
accent="bg-primary/10 text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
icon={KeyRound}
|
||||
label="API 密钥"
|
||||
value={keysLoading ? '—' : `${apiKeys?.length ?? 0}`}
|
||||
hint="已创建的密钥数"
|
||||
accent="bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
|
||||
/>
|
||||
<StatCard
|
||||
icon={ShoppingBag}
|
||||
label="已购应用"
|
||||
value={appsLoading ? '—' : `${purchasedApps?.length ?? 0}`}
|
||||
hint="已下单的应用服务"
|
||||
accent="bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Change password */}
|
||||
<ChangePasswordSection />
|
||||
|
||||
{/* Login logs */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History size={16} className="text-muted-foreground" /> 登录记录
|
||||
</CardTitle>
|
||||
<CardDescription>最近账户登录 IP 与设备信息</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2.5">时间</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">IP</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">User Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logsLoading ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-10 text-muted-foreground">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||
</td>
|
||||
</tr>
|
||||
) : logItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-10 text-muted-foreground">
|
||||
暂无登录记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logItems.map((log) => (
|
||||
<tr key={log.id} className="border-t border-border hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
|
||||
{formatTime(log.login_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-foreground tabular-nums">{log.ip}</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground truncate max-w-[280px]">
|
||||
{log.user_agent}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
第 {logPage} / {logTotalPages} 页 · 共 {formatNumber(logTotal)} 条
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={logPage <= 1 || logsFetching}
|
||||
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft size={14} /> 上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={logPage >= logTotalPages || logsFetching}
|
||||
onClick={() => setLogPage((p) => Math.min(logTotalPages, p + 1))}
|
||||
>
|
||||
下一页 <ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Two-column: API keys + purchased apps */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound size={16} className="text-muted-foreground" /> API 密钥
|
||||
</CardTitle>
|
||||
<CardDescription>用于第三方集成的访问凭证</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 新建密钥 */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label htmlFor="pf-newkey" className="text-xs">新建密钥名称</Label>
|
||||
<Input
|
||||
id="pf-newkey"
|
||||
placeholder="例如:我的应用"
|
||||
value={newKeyName}
|
||||
onChange={(e) => { setNewKeyName(e.target.value); setKeyError(null); }}
|
||||
disabled={createApiKey.isPending}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={createApiKey.isPending || !newKeyName.trim()}
|
||||
onClick={() =>
|
||||
createApiKey.mutate(newKeyName.trim(), {
|
||||
onSuccess: (res) => {
|
||||
setCreatedKey(res.api_key);
|
||||
setNewKeyName('');
|
||||
},
|
||||
onError: (err) => setKeyError(err instanceof Error ? err.message : '创建失败'),
|
||||
})
|
||||
}
|
||||
>
|
||||
{createApiKey.isPending && <Loader2 size={13} className="animate-spin" />}
|
||||
<Plus size={13} /> 创建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{keyError && <p className="text-xs text-destructive">{keyError}</p>}
|
||||
|
||||
{createdKey && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/30 p-3 space-y-1">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300 flex items-center gap-1">
|
||||
<CheckCircle2 size={13} /> 密钥已创建,请立即复制保存(仅显示一次)
|
||||
</p>
|
||||
<p className="text-xs font-mono text-foreground break-all select-all">{createdKey}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keysLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||
</div>
|
||||
) : !apiKeys || apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">尚未创建密钥</div>
|
||||
) : (
|
||||
apiKeys.map((k) => {
|
||||
const active = k.status === 'active';
|
||||
const revoked = k.status === 'revoked';
|
||||
return (
|
||||
<div
|
||||
key={k.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-foreground font-medium truncate">{k.name}</p>
|
||||
<p className="text-xs text-muted-foreground tabular-nums font-mono truncate">
|
||||
{k.key_preview}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Badge variant={active ? 'default' : 'secondary'}>
|
||||
{active ? '启用' : revoked ? '已撤销' : k.status}
|
||||
</Badge>
|
||||
{!revoked && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title={active ? '禁用' : '启用'}
|
||||
disabled={updateApiKey.isPending}
|
||||
onClick={() =>
|
||||
updateApiKey.mutate({
|
||||
keyId: k.id,
|
||||
status: active ? 'disabled' : 'enabled',
|
||||
})
|
||||
}
|
||||
>
|
||||
{active ? <Trash2 size={13} /> : <CheckCircle2 size={13} />}
|
||||
</Button>
|
||||
)}
|
||||
{!revoked && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
title="撤销"
|
||||
disabled={updateApiKey.isPending}
|
||||
onClick={() =>
|
||||
updateApiKey.mutate({ keyId: k.id, status: 'revoked' })
|
||||
}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Purchased apps */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShoppingBag size={16} className="text-muted-foreground" /> 已购应用
|
||||
</CardTitle>
|
||||
<CardDescription>已下单的应用服务列表</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{appsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
|
||||
</div>
|
||||
) : !purchasedApps || purchasedApps.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">暂无已购应用</div>
|
||||
) : (
|
||||
purchasedApps.map((app) => (
|
||||
<div
|
||||
key={app.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5"
|
||||
>
|
||||
{app.app_icon ? (
|
||||
<img
|
||||
src={app.app_icon}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-md bg-muted flex items-center justify-center text-muted-foreground shrink-0">
|
||||
<ShoppingBag size={16} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground font-medium truncate">{app.app_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{app.app_type} · {formatTime(app.purchased_at).split(' ')[0]}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-foreground tabular-nums shrink-0">
|
||||
¥{formatNumber(app.price)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -0,0 +1,395 @@
|
||||
/* ===== 七虎AI 统一设计系统 ===== */
|
||||
/* 桥接旧版 CSS 变量 + Tailwind shadcn/ui 主题 */
|
||||
|
||||
/* Google Fonts */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
@source "../";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
/* Tailwind CSS 主题 (shadcn/ui 兼容) */
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-2xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-success: #219B5E;
|
||||
--color-warning: #E8922C;
|
||||
--color-info: #3B82F6;
|
||||
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
/* Tailwind 主题色值 (CSS 变量) */
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* --- Tailwind shadcn/ui 变量 --- */
|
||||
--background: #FFFBF5;
|
||||
--foreground: #2A2018;
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #2A2018;
|
||||
--primary: #D97706;
|
||||
--primary-foreground: #FFFFFF;
|
||||
--secondary: #F7F1E8;
|
||||
--secondary-foreground: #2A2018;
|
||||
--muted: #F7F1E8;
|
||||
--muted-foreground: #9A8B76;
|
||||
--accent: #D97706;
|
||||
--accent-foreground: #FFFFFF;
|
||||
--destructive: #D9383A;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
--border: #ECE3D6;
|
||||
--input: #ECE3D6;
|
||||
--ring: #D97706;
|
||||
|
||||
/* --- 旧版变量 (兼容旧页面) --- */
|
||||
--color-bg-primary: #FFFBF5;
|
||||
--color-bg-secondary: #FFFFFF;
|
||||
--color-bg-tertiary: #F7F1E8;
|
||||
--color-bg-hover: #F2E9DB;
|
||||
--color-bg-active: #F0E4D0;
|
||||
|
||||
--color-text-primary: #2A2018;
|
||||
--color-text-secondary: #6B5D49;
|
||||
--color-text-tertiary: #9A8B76;
|
||||
--color-text-disabled: #C4B8A4;
|
||||
--color-text-inverse: #FFFFFF;
|
||||
|
||||
--color-accent: #D97706;
|
||||
--color-accent-hover: #B45309;
|
||||
--color-accent-light: #FEF3C7;
|
||||
--color-accent-subtle: #FFFBEB;
|
||||
|
||||
--color-success: #219B5E;
|
||||
--color-success-light: #E6F7EE;
|
||||
--color-warning: #E8922C;
|
||||
--color-warning-light: #FEF4E8;
|
||||
--color-error: #D9383A;
|
||||
--color-error-light: #FDEBEB;
|
||||
--color-info: #3B82F6;
|
||||
--color-info-light: #EBF2FE;
|
||||
|
||||
--color-border: #E3E6EB;
|
||||
--color-border-light: #EEF0F4;
|
||||
--color-border-hover: #CDD2DA;
|
||||
|
||||
--color-sidebar-bg: #FBF7F0;
|
||||
--color-sidebar-hover: #F4ECDD;
|
||||
--color-sidebar-active: #F0E4D0;
|
||||
|
||||
/* --- 阴影 --- */
|
||||
--shadow-xs: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
|
||||
--shadow-lg: 0 12px 32px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04);
|
||||
--shadow-xl: 0 20px 48px rgba(0,0,0,0.1), 0 4px 12px rgba(0,0,0,0.06);
|
||||
|
||||
/* --- 间距 --- */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 20px;
|
||||
--space-2xl: 24px;
|
||||
--space-3xl: 32px;
|
||||
--space-4xl: 40px;
|
||||
--space-5xl: 48px;
|
||||
|
||||
/* --- 字号 --- */
|
||||
--text-xs: 11px;
|
||||
--text-sm: 13px;
|
||||
--text-base: 14px;
|
||||
--text-md: 15px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 22px;
|
||||
--text-2xl: 28px;
|
||||
--text-3xl: 36px;
|
||||
|
||||
/* --- 布局 --- */
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-collapsed: 64px;
|
||||
--header-height: 56px;
|
||||
|
||||
/* --- 过渡 --- */
|
||||
--transition-fast: 120ms ease;
|
||||
--transition-normal: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
/* 暗色模式 */
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.dark, [data-theme="dark"] {
|
||||
--background: #1C1814;
|
||||
--foreground: #F5EFE6;
|
||||
--card: #272019;
|
||||
--card-foreground: #F5EFE6;
|
||||
--primary: #F59E0B;
|
||||
--primary-foreground: #1C1814;
|
||||
--secondary: #2E281F;
|
||||
--secondary-foreground: #F5EFE6;
|
||||
--muted: #2E281F;
|
||||
--muted-foreground: #A9967E;
|
||||
--accent: #F59E0B;
|
||||
--accent-foreground: #1C1814;
|
||||
--destructive: #f87171;
|
||||
--destructive-foreground: #1C1814;
|
||||
--border: #3D352A;
|
||||
--input: #3D352A;
|
||||
--ring: #F59E0B;
|
||||
|
||||
/* 旧版变量暗色 */
|
||||
--color-bg-primary: #1C1814;
|
||||
--color-bg-secondary: #272019;
|
||||
--color-bg-tertiary: #2E281F;
|
||||
--color-bg-hover: #3D352A;
|
||||
--color-bg-active: #4D4334;
|
||||
--color-text-primary: #F5EFE6;
|
||||
--color-text-secondary: #D6C7B3;
|
||||
--color-text-tertiary: #A9967E;
|
||||
--color-text-disabled: #5C5240;
|
||||
--color-accent: #F59E0B;
|
||||
--color-accent-hover: #FBBF24;
|
||||
--color-accent-light: rgba(245,158,11,0.15);
|
||||
--color-border: #3D352A;
|
||||
--color-border-light: #272019;
|
||||
--color-border-hover: #4D4334;
|
||||
--color-sidebar-bg: #272019;
|
||||
--color-sidebar-hover: #3D352A;
|
||||
--color-sidebar-active: #4D4334;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0,0,0,0.2);
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
|
||||
--shadow-lg: 0 12px 32px rgba(0,0,0,0.4);
|
||||
--shadow-xl: 0 20px 48px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
/* 全局基础 */
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--text-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--foreground);
|
||||
background-color: var(--background);
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--primary-foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
/* 滚动条 */
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
/* 通用工具类 (旧页面兼容) */
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.page-container {
|
||||
padding: var(--space-2xl);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--color-warning-light);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: var(--color-error-light);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(217,119,6,0.1);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
/* 动画 */
|
||||
/* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Fresh, never-cached asset path per build. The public CDN in front of
|
||||
// ai.qihuyun.com caches the canonical /assets/*.js|css URLs for 30 days. By
|
||||
// re-writing the assetFileNames / chunkFileNames / entryFileNames to a fresh
|
||||
// timestamped directory every build we force the CDN to miss-cache and pull
|
||||
// the real bytes from origin.
|
||||
const BUILD_STAMP = 'v' + new Date().toISOString().replace(/[-:TZ]/g, '').slice(0, 12);
|
||||
|
||||
// Vite emits `<script type="module" crossorigin>` by default. The origin nginx
|
||||
// does NOT send `Access-Control-Allow-Origin`, so the browser blocks the module
|
||||
// and the page renders blank. Strip the attribute from the generated index.html.
|
||||
function stripCrossorigin() {
|
||||
return {
|
||||
name: 'strip-crossorigin',
|
||||
enforce: 'post',
|
||||
transformIndexHtml(html: string) {
|
||||
return html.replace(/\s+crossorigin(="[^"]*")?/g, '');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Force terser as the minifier. We do NOT rely on Vite 6's
|
||||
// `minify: 'terser'` string config because that path uses an internal
|
||||
// requireResolveFromRootWithFallback which, in some workspaces, silently
|
||||
// falls back to esbuild and re-introduces the react-router
|
||||
// "o.slice is not a function" runtime crash. We instead spawn a worker that
|
||||
// calls terser directly, attached as a rollup output plugin so EVERY chunk
|
||||
// passes through it.
|
||||
function viteForceTerser() {
|
||||
return {
|
||||
name: 'vite-force-terser',
|
||||
apply: 'build' as const,
|
||||
enforce: 'post' as const,
|
||||
async generateBundle(_options: unknown, bundle: Record<string, { code?: string; type?: string }>) {
|
||||
// TEMP DIAGNOSTIC: do NOT re-minify. We are testing whether the
|
||||
// react-router "i.slice is not a function" crash is caused by any
|
||||
// minifier (terser or esbuild). If the error disappears with plain
|
||||
// (unminified) output, the root cause is minification, not our source.
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), stripCrossorigin(), viteForceTerser()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
dedupe: ['react', 'react-dom', 'react-router', 'react-router-dom'],
|
||||
},
|
||||
build: {
|
||||
// We bypass Vite's built-in minifier entirely. Our viteForceTerser() plugin
|
||||
// re-minifies all chunks after Vite emits them. With esbuild off, the
|
||||
// react-router internal Symbol.iterator / slice paths stay intact.
|
||||
minify: false,
|
||||
assetsDir: `assets/${BUILD_STAMP}`,
|
||||
cssMinify: 'lightningcss', // preserves more than esbuild CSS minify
|
||||
modulePreload: {
|
||||
polyfill: false,
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: `assets/${BUILD_STAMP}/[name]-[hash][extname]`,
|
||||
chunkFileNames: `assets/${BUILD_STAMP}/[name]-[hash].js`,
|
||||
entryFileNames: `assets/${BUILD_STAMP}/[name]-[hash].js`,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user