Compare commits

..
21 Commits
Author SHA1 Message Date
agent 25f3edac0a feat(admin): 后台脚手架-布局/路由/守卫 + Dashboard 真实数据页 (#1013)
CI / gate (push) Has been cancelled
2026-08-24 11:32:18 +08:00
qihuaiflow 7b8c3ba2dd feat(frontend): Notifications + Evolution 真实数据页接用户端 API (#1008/#1009) 2026-08-24 08:47:36 +08:00
QihuAI Design 8f30a588fb feat(frontend): #1001/#1003/#1004/#1005 前台4页契约对齐真实API(chat/knowledge/im-bot/my-apps) + 契约层解包 2026-08-23 15:54:43 +08:00
QihuAI Design 1ba81ba0d1 feat(frontend-v2): /market 技能市场真实数据页 - 重写 MarketApp 类型+marketApi 对齐真实后端契约(#993/#994),新增 MarketPage 替换 ComingSoon(#995) 2026-08-23 13:50:11 +08:00
agent 66f696c1e6 feat(profile): 补全写操作-改资料/改密/API Key 增删状态切换 (#991) 2026-08-23 13:21:16 +08:00
QihuAI Design 09440c2577 feat(frontend-v2): ProfilePage 接 profile API - client/useApi/App 对齐真实契约 (#987-#990) 2026-08-23 12:30:20 +08:00
QihuAI Design 8a9c2e4868 feat(frontend-v2): ProfilePage 真实数据页 (#987-#990) 接 profile API 6 区块 2026-08-23 12:28:41 +08:00
QihuAI Design 39fcdbf6da feat(frontend-v2): ProfilePage 真实数据页 (#987-#990) - 接 profile API 6 区块 2026-08-23 12:26:56 +08:00
QihuAI Design 910394423d feat(frontend-v2): #986 Token 驾驶舱真实数据页 (token/overview|rankings|logs|balance) 2026-08-23 12:02:37 +08:00
QihuAI Design 4a2920e637 #985 阶段3: 9个占位路由升级为 amber 金暖白 ComingSoon 页 (统一品牌感+功能预告) 2026-08-23 11:36:02 +08:00
QihuAI Design 6f192a89cf #983/#984 amber 金暖白设计语言:globals.css 主色令牌 indigo->amber + 3处硬编码色修正 2026-08-23 11:21:22 +08:00
QihuAI Design 8f57956904 #978 safe-align: 固化 frontend-v2 空白页修复真相源
- 新增 frontend-v2/ 为生产前端真相源(此前仅容器 dist,未入 git)
- vite.config.ts: minify:false 根治压缩器压坏 react-router 6.28 (o.slice)
- useApi.ts: asArray select 防御非裸数组返回值
- LandingPage/AppServices: Array.isArray coerce 双重防御 (apps.slice)
- 关联: INCIDENT-2026-08-23-blank-page.md / SOP-FRONTEND-ITERATIVE-DEVELOPMENT.md
- 公网验证通过 2026-08-23 09:31 (DOM 24493B, 无 slice 错误)
2026-08-23 09:36:35 +08:00
qihu-bot 552a962123 #977 时间错位8小时彻底修复 - _beijing_now返回aware
CI / gate (push) Has been cancelled
根因: _beijing_now返回naive datetime(无时区), PG timestamptz列按UTC误解, 存13:53UTC前端按Asia/Shanghai显示21:53错位8小时。修复: 去掉replace(tzinfo=None)返回aware datetime(带+08:00), PG正确转UTC存储, 前端正确显示。
2026-08-18 14:01:57 +08:00
qihu-bot ab11283d92 #976 微信对话同步WebClaw修复 + admin连接时间统一北京时间
CI / gate (push) Has been cancelled
1.mirror_to_ai_chat: _uuid→uuid(NameError被except吞了, 对话从未同步到webclaw) 2.im_bot_admin加_beijing_now并替换5处datetime.now()(容器UTC存naive时间前端按+08:00展示错位8小时)
2026-08-18 13:47:07 +08:00
qihu-bot f975672b0e #974 微信Bot sendtyping彻底修复
CI / gate (push) Has been cancelled
根因: sendtyping三个bug - 1.ilink_user_id误用bot ID(应from_user_id用户ID) 2.请求缺base_info 3.未检查ret==0。另发现账号session已过期(-14 session timeout)是sendtyping失效+文本失败的根源。修复: getconfig/sendtyping补base_info+正确ilink_user_id+ret==0判断, -14时写session_expired日志。
2026-08-18 11:30:15 +08:00
qihu-bot 9cf4378bf5 #973 微信Bot文本回复慢+间歇空修复
CI / gate (push) Has been cancelled
根因: 多轮对话时图片URL(含AWS token)存进历史+104技能清单注入prompt, 导致推理模型响应慢(72s)且间歇返回空content。修复: 1.IM侧精简prompt(db=None只注入静态文档) 2.空content重试(同模型最多3轮) 3.图片回复存[图片]占位符不存URL 4.历史每条截断500字符。验证: 你好15s/1+1=4s可正常回复。
2026-08-18 11:15:50 +08:00
qihu-bot 1c0dc59842 #972 微信Bot多轮对话修复 - sendmessage补client_id+run_id
CI / gate (push) Has been cancelled
对齐openclaw: payload补client_id(随机uuid去重)+run_id+from_user_id。缺失client_id导致多轮对话第二轮消息被iLink误判重复丢弃(第一轮成功第二轮失败)。
2026-08-15 19:42:15 +08:00
qihu-bot 51d8df6e6a #971 微信Bot P2 - 接收侧媒体下载解密
CI / gate (push) Has been cancelled
实现CDN媒体下载解密(parse_aes_key支持base64(raw16B)和base64(hex32)两种编码)+extract_inbound_media(图片/语音/文件/视频)。接收循环检测媒体消息下载解密保存到/tmp。图片已保存供后续vision模型接入。
2026-08-15 18:40:01 +08:00
qihu-bot e284d43cc7 #970 微信Bot P1增强 - sendtyping + 语音ASR + Session Guard
CI / gate (push) Has been cancelled
1.语音消息ASR: 提取voice_item.text(微信自带转文字) 2.sendtyping正在输入状态: getconfig拿typing_ticket缓存24h + sendtyping发送 3.Session Guard增强: 会话过期记录告警日志供前台提示重扫
2026-08-15 18:13:47 +08:00
qihu-bot 46e9f1e1e6 #969 微信Bot媒体内联发送 - iLink CDN AES加密上传
CI / gate (push) Has been cancelled
参考微信官方openclaw-weixin源码实现媒体内联发送链路:下载媒体→getuploadurl→AES-128-ECB加密→上传微信CDN→内联发送(type=2/5)。新增im_ilink_media.py模块。失败降级文本链接兜底。修复pg18/casdoor容器未启动问题。
2026-08-15 17:45:47 +08:00
qihu-bot 894eeff65a docs: 微信Bot iLink协议重构方案 v1.0
CI / gate (push) Has been cancelled
分析微信官方iLink(ClawBot)协议完整能力,对比平台现状,识别5项差距。核心:媒体内联发送(CDN AES-128-ECB加密)替代文本链接降级。分5阶段实施(P0-P2)。
2026-08-15 17:14:07 +08:00
58 changed files with 14086 additions and 30 deletions

No files matched your search

+12 -4
View File
@@ -14,7 +14,15 @@ import hmac
import time import time
import threading import threading
import requests 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 typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query 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) (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)"""), 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, {"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: else:
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET _execute_raw(db, text("""UPDATE im_bot_wechat_account SET
bot_token=:bot_token, ilink_bot_id=:ilink_bot_id, baseurl=:baseurl, 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"""), 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, {"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) row = _wechat_row(db)
aid = _row_to_dict(row)["id"] if row else 1 aid = _row_to_dict(row)["id"] if row else 1
_start_wechat_receiver(aid) _start_wechat_receiver(aid)
@@ -821,7 +829,7 @@ async def wechat_disconnect(user=Depends(get_current_user)):
row = _wechat_row(db) row = _wechat_row(db)
if row: if row:
_execute_raw(db, text("UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', updated_at=:updated_at WHERE id=:id"), _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": "已断开个人微信连接"} return {"success": True, "message": "已断开个人微信连接"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
+184 -24
View File
@@ -18,6 +18,7 @@ import os
import random import random
import threading import threading
import time import time
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from urllib.parse import quote as _quote from urllib.parse import quote as _quote
@@ -39,8 +40,13 @@ TZ_BEIJING = timezone(timedelta(hours=8))
def _beijing_now() -> datetime: def _beijing_now() -> datetime:
"""返回北京时间的 naive datetime(去掉 tzinfo 以便直接存入 MySQL DATETIME)。""" """返回带时区的北京时间(aware datetime)。
return datetime.now(TZ_BEIJING).replace(tzinfo=None)
#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) # iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID)
@@ -54,6 +60,9 @@ WECHAT_TEXT_MAX_LEN = 1500
# 每个已连接账号一个后台接收线程,key = 账号表主键 id # 每个已连接账号一个后台接收线程,key = 账号表主键 id
_wechat_receiver_threads: dict[int, threading.Thread] = {} _wechat_receiver_threads: dict[int, threading.Thread] = {}
# typing_ticket 缓存:{ilink_user_id: (ticket, 写入时间戳)},24h 有效
_typing_ticket_cache: dict[str, tuple[str, float]] = {}
# ============================================================ # ============================================================
# 通用 DB 工具 # 通用 DB 工具
@@ -127,7 +136,11 @@ def _recent_history(user_id: str, limit: int = 6) -> str:
for r in reversed(rows): for r in reversed(rows):
dd = _row_to_dict(r) dd = _row_to_dict(r)
role = "用户" if dd["role"] == "user" else "七虎AI" 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) return "\n".join(pairs)
except Exception: except Exception:
return "" 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 失效。""" ret=-14=会话过期需重扫;ret=-2=token 失效。"""
payload = { payload = {
"msg": { "msg": {
"from_user_id": "",
"to_user_id": to_user_id, "to_user_id": to_user_id,
"client_id": uuid.uuid4().hex,
"message_type": 2, "message_type": 2,
"message_state": 2, "message_state": 2,
"context_token": context_token, "context_token": context_token,
"item_list": item_list, "item_list": item_list,
"run_id": uuid.uuid4().hex,
} }
} }
last_body = {} 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, {} 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: def _platform_relay_url(raw_url: str) -> str:
"""把 AGNES 等境外媒体 URL 改写为平台中转地址,使微信/iLink 可内联渲染。 """把 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, def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
media_type: str, url: str): media_type: str, url: str):
"""发送媒体(图片/视频)。 """发送媒体(图片/视频)。#969 iLink CDN 内联发送
问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时。 优先走 iLink 官方 CDN 加密上传链路,实现图片/视频真正内联显示;
解决:改写为平台中转地址(经香港代理可达 AGNES)。 失败时降级为纯文本链接(点击访问),确保会话不中断。
2026-07-31 兼容修复:iLink 对 type=2/5 的媒体 item 返回 ret=-2 invalid arguments,
且旧代码把 ret=-2 误判为 token 失效,导致连接假性断开。先以纯文本链接形式发送,
确保用户可点击访问且不会破坏会话;待拿到 iLink 媒体协议文档后再恢复内联 item。
""" """
relay_url = _platform_relay_url(url) relay_url = _platform_relay_url(url)
# 尝试内联发送(iLink CDN 加密上传)
try: try:
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f: from app.api.im_ilink_media import (
_f.write(_beijing_now().isoformat() + " MEDIA_URL_LEN=%s type=%s\n" % (len(relay_url), media_type)) MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO,
except Exception: download_media_bytes, upload_media_to_wechat_cdn,
pass 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": if media_type == "image":
caption = "🖼️ 图片已生成,点击查看:" + relay_url caption = "🖼️ 图片已生成,点击查看:" + relay_url
else: 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: def _wechat_extract_text(msg: dict) -> str:
parts = [] parts = []
for it in (msg.get("item_list") or []): 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"]) 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: if not parts:
for k in ("text", "content", "message", "msg"): for k in ("text", "content", "message", "msg"):
v = msg.get(k) v = msg.get(k)
@@ -354,6 +463,42 @@ def _wechat_extract_text(msg: dict) -> str:
return "\n".join(parts) 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: def _wechat_sender_id(m: dict) -> str:
"""从入站消息中提取发送者 id(兼容多套字段名)。""" """从入站消息中提取发送者 id(兼容多套字段名)。"""
for k in ("from_user_id", "from_user_name", "from_username", "user_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}) ), {"uid": user_id})
if rows: if rows:
return _row_to_dict(rows[0])["id"] return _row_to_dict(rows[0])["id"]
cid = str(_uuid.uuid4()) cid = str(uuid.uuid4())
now = _beijing_now() now = _beijing_now()
_execute_raw(db, text( _execute_raw(db, text(
"INSERT INTO conversations " "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) " "INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
"VALUES (:id, :cid, :role, :content, 0, :c)" "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(), "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): def _mark_wechat_expired(account_id: int):
"""发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。""" """发送会话失效(ret=-14/-2):标记账号需重扫,清除失效 token,并记录告警日志。"""
db = None db = None
try: try:
db = SessionLocal() db = SessionLocal()
@@ -465,6 +610,14 @@ def _mark_wechat_expired(account_id: int):
"UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, " "UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, "
"updated_at=:ts WHERE id=:aid" "updated_at=:ts WHERE id=:aid"
), {"aid": account_id, "ts": _beijing_now()}) ), {"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: except Exception:
pass pass
finally: finally:
@@ -497,7 +650,10 @@ def _wechat_receiver_loop(account_id: int):
continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理 continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理
content = _wechat_extract_text(m) content = _wechat_extract_text(m)
if not content: if not content:
continue # 入站媒体(图片/语音/文件/视频):下载解密保存,供 LLM 感知
content = _extract_and_save_inbound_media(m)
if not content:
continue
sender = _wechat_sender_id(m) sender = _wechat_sender_id(m)
ctx = _wechat_context_token(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") + " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
except Exception: except Exception:
pass 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 import logging as _lrx, inspect as _insp
_lrx.warning(f'[receiver] calling _qihua_reply({repr(content)}, {repr(owner_uid)})') _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) ok, ret, _ = _wechat_send_media(baseurl, bot_token, sender, ctx, media_type, media_url)
# 对话历史里存可渲染的媒体标签(经平台中转,AI 聊天 WeXin Bot 窗与历史页均可内联显示) # 对话历史里存可渲染的媒体标签(经平台中转,AI 聊天 WeXin Bot 窗与历史页均可内联显示)
relay_url = _platform_relay_url(media_url) relay_url = _platform_relay_url(media_url)
reply_for_log = ( # #973:对话历史只存简短占位符,不存超长 relay URL(含 AWS token,会撑爆多轮 prompt)
f'<img src="{relay_url}" alt="生成图片"/>' reply_for_log = "[图片]" if media_type == "image" else "[视频]"
if media_type == "image"
else f'<video src="{relay_url}" controls></video>'
)
else: else:
reply_text = reply if isinstance(reply, str) else str(reply) reply_text = reply if isinstance(reply, str) else str(reply)
ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply_text) ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply_text)
+330
View File
@@ -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
+6 -2
View File
@@ -277,10 +277,11 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
f"mcp_tools={len(mcp_function_definitions)}" f"mcp_tools={len(mcp_function_definitions)}"
) )
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单) # 平台知识库注入(#973:IM 侧精简——只注入静态文档,不注入 104 动态清单,
# 避免 prompt 过长(6892字符)导致推理模型响应慢(63s)且间歇性返回空 content)
kb_context = "" kb_context = ""
try: try:
pk = get_platform_knowledge(db=db) pk = get_platform_knowledge(db=None)
if pk: if pk:
kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}" kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
except Exception: 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: if round_num >= max_rounds - 1:
break break
continue continue
# #973:空 content 且未到最后一轮 → 重试(推理模型间歇性返回空)
if not ai_content and round_num < max_rounds - 1:
continue
break break
# 检查本轮调用是否成功 # 检查本轮调用是否成功
+316
View File
@@ -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`
+126
View File
@@ -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;
+767
View File
@@ -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}`),
};
+496
View File
@@ -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>
);
}
+13
View File
@@ -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>
+4577
View File
File diff suppressed because it is too large. Load diff
+45
View File
@@ -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"
}
}
+4
View File
@@ -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

+208
View File
@@ -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;
+816
View File
@@ -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>
);
}
+25
View File
@@ -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 };
+29
View File
@@ -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 };
+52
View File
@@ -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 };
+46
View File
@@ -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 };
+66
View File
@@ -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 };
+21
View File
@@ -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 };
+18
View File
@@ -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 };
+43
View File
@@ -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 };
+21
View File
@@ -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 };
+312
View File
@@ -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, // 翻页时保留旧数据,避免闪烁
});
}
+11
View File
@@ -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,
},
},
});
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+24
View File
@@ -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>
);
+18
View File
@@ -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>
);
+10
View File
@@ -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>
);
}
+214
View File
@@ -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;
+395
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -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"]
}
+86
View File
@@ -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,
},
},
},
});