Merge branch 'dev' of https://github.com/Mai-with-u/MaiBot into dev
This commit is contained in:
24
.gitignore
vendored
24
.gitignore
vendored
@@ -46,8 +46,30 @@ config/lpmm_config.toml.bak
|
||||
template/compare/bot_config_template.toml
|
||||
template/compare/model_config_template.toml
|
||||
CLAUDE.md
|
||||
MaiBot-Dashboard/
|
||||
cloudflare-workers/
|
||||
log_viewer/
|
||||
dev/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
result.json
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
199
README.md
199
README.md
@@ -1,117 +1,114 @@
|
||||
<img src="depends-data/maimai.png" alt="MaiBot" title="作者:略nd" width="300">
|
||||
|
||||
# 麦麦!MaiCore-MaiBot
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://deepwiki.com/DrSmoothl/MaiBot)
|
||||
|
||||
<div style="text-align: center">
|
||||
<strong>
|
||||
<a href="https://www.bilibili.com/video/BV1amAneGE3P">🌟 演示视频</a> |
|
||||
<a href="#-更新和安装">🚀 快速入门</a> |
|
||||
<a href="#-文档">📃 教程</a> |
|
||||
<a href="#-讨论">💬 讨论</a> |
|
||||
<a href="#-贡献和致谢">🙋 贡献指南</a>
|
||||
</strong>
|
||||
<div align="center">
|
||||
<h1>麦麦 MaiBot <sub><small>MaiCore</small></sub></h1>
|
||||
|
||||
<!-- Badges Row -->
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Python-3.10+-blue" alt="Python Version">
|
||||
<img src="https://img.shields.io/github/license/Mai-with-u/MaiBot?label=%E5%8D%8F%E8%AE%AE" alt="License">
|
||||
<img src="https://img.shields.io/badge/状态-开发中-yellow" alt="Status">
|
||||
<img src="https://img.shields.io/github/contributors/Mai-with-u/MaiBot.svg?style=flat&label=%E8%B4%A1%E7%8C%AE%E8%80%85" alt="Contributors">
|
||||
<img src="https://img.shields.io/github/forks/Mai-with-u/MaiBot.svg?style=flat&label=%E5%88%86%E6%94%AF%E6%95%B0" alt="Forks">
|
||||
<img src="https://img.shields.io/github/stars/Mai-with-u/MaiBot?style=flat&label=%E6%98%9F%E6%A0%87%E6%95%B0" alt="Stars">
|
||||
<a href="https://deepwiki.com/DrSmoothl/MaiBot"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<!-- Mascot on the Right (Float) -->
|
||||
<img src="depends-data/maimai-v2.png" align="right" width="40%" alt="MaiBot Character" style="margin-left: 20px; margin-bottom: 20px;">
|
||||
|
||||
## 🎉 介绍
|
||||
|
||||
**🍔MaiCore 是一个基于大语言模型的可交互智能体**
|
||||
**🍔 MaiCore 是一个基于大语言模型的可交互智能体**
|
||||
|
||||
- 💭 **拟人构建的prompt**:使用自然语言风格构建回复器的prompt,实现近似人类言语习惯的回复。
|
||||
- 💭 **行为规划**:在合适的时间说话,使用合适的动作
|
||||
- 🧠 **表达学习**:学习群友的说话风格和表达方式,学会真实人类的说话风格
|
||||
- 🤔 **黑话学习**:自主的学习没有见过的词语,尝试理解并认知含义
|
||||
- 🔌 **插件系统**:提供API和事件系统,可编写丰富插件。
|
||||
- 💝 **情感表达**:情绪系统和表情包系统。
|
||||
MaiBot 不仅仅是一个机器人,她致力于成为一个活跃在 QQ 群聊中的“生命体”。她不追求完美,但追求真实。
|
||||
|
||||
<div style="text-align: center">
|
||||
<a href="https://www.bilibili.com/video/BV1amAneGE3P" target="_blank">
|
||||
- 💭 **拟人构建**:使用自然语言风格构建 Prompt,回复贴近人类习惯。
|
||||
- 🎭 **行为规划**:懂得在合适的时间说话,使用合适的动作。
|
||||
- 🧠 **表达学习**:模仿群友的说话风格,学习黑话,不断进化。
|
||||
- 🔌 **插件系统**:提供强大的 API 和事件系统,无限扩展可能。
|
||||
- 💝 **情感表达**:拥有独立的情绪系统和表情包互动能力。
|
||||
|
||||
### 🚀 快速导航
|
||||
<p>
|
||||
<a href="https://www.bilibili.com/video/BV1amAneGE3P">🌟 演示视频</a> |
|
||||
<a href="#-更新和安装">📦 快速入门</a> |
|
||||
<a href="#-部署教程">📃 核心文档</a> |
|
||||
<a href="#-讨论与社区">💬 加入社区</a>
|
||||
</p>
|
||||
|
||||
<!-- Clear float to ensure subsequent content starts below the image area if text is short -->
|
||||
<br clear="both">
|
||||
|
||||
<div align="center">
|
||||
<br>
|
||||
<h3>🎥 精彩演示</h3>
|
||||
<a href="https://www.bilibili.com/video/BV1amAneGE3P" target="_blank">
|
||||
<picture>
|
||||
<source media="(max-width: 600px)" srcset="depends-data/video.png" width="100%">
|
||||
<img src="depends-data/video.png" width="30%" alt="麦麦演示视频">
|
||||
<img src="depends-data/video.png" width="60%" alt="麦麦演示视频" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
|
||||
</picture>
|
||||
<br />
|
||||
👆 点击观看麦麦演示视频 👆
|
||||
</a>
|
||||
<br>
|
||||
<small>👆 点击观看麦麦演示视频 👆</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🔥 更新和安装
|
||||
|
||||
> **最新版本: v0.12.2** ([📄 更新日志](changelogs/changelog.md))
|
||||
|
||||
**最新版本: v0.12.0** ([更新日志](changelogs/changelog.md))
|
||||
- **下载**: 前往 [Release](https://github.com/MaiM-with-u/MaiBot/releases/) 页面下载最新版本
|
||||
- **启动器**: [Mailauncher](https://github.com/MaiM-with-u/mailauncher/releases/) (仅支持 MacOS, 早期开发中)
|
||||
|
||||
| 分支 | 说明 |
|
||||
| :--- | :--- |
|
||||
| `main` | ✅ **稳定发布版本 (推荐)** |
|
||||
| `dev` | 🚧 开发测试版本 (不稳定) |
|
||||
| `classical` | 🛑 经典版本 (停止维护) |
|
||||
|
||||
可前往 [Release](https://github.com/MaiM-with-u/MaiBot/releases/) 页面下载最新版本
|
||||
|
||||
可前往 [启动器发布页面](https://github.com/MaiM-with-u/mailauncher/releases/)下载最新启动器
|
||||
|
||||
注意,启动器处于早期开发版本,仅支持MacOS
|
||||
|
||||
**GitHub 分支说明:**
|
||||
- `main`: 稳定发布版本(推荐)
|
||||
|
||||
- `dev`: 开发测试版本(不稳定)
|
||||
|
||||
- `classical`: 经典版本(停止维护)
|
||||
|
||||
### 最新版本部署教程
|
||||
- [🚀 最新版本部署教程](https://docs.mai-mai.org/manual/deployment/mmc_deploy_windows.html) - 基于 MaiCore 的新版本部署方式(与旧版本不兼容)
|
||||
### 📚 部署教程
|
||||
👉 **[🚀 最新版本部署教程](https://docs.mai-mai.org/manual/deployment/mmc_deploy_windows.html)**
|
||||
*(注意:MaiCore 新版本部署方式与旧版本不兼容)*
|
||||
|
||||
> [!WARNING]
|
||||
> - 项目处于活跃开发阶段,功能和 API 可能随时调整。
|
||||
> - 有问题可以提交 Issue 。
|
||||
> - QQ 机器人存在被限制风险,请自行了解,谨慎使用。
|
||||
> - 由于程序处于开发中,可能消耗较多 token。
|
||||
> - ⚠️ 项目处于活跃开发阶段,API 可能随时调整。
|
||||
> - ⚠️ QQ 机器人存在风控风险,请谨慎使用。
|
||||
> - ⚠️ AI 模型运行可能消耗较多 Token。
|
||||
|
||||
## 💬 讨论
|
||||
---
|
||||
|
||||
**技术交流群/答疑群:**
|
||||
[麦麦脑电图](https://qm.qq.com/q/RzmCiRtHEW) |
|
||||
[麦麦大脑磁共振](https://qm.qq.com/q/VQ3XZrWgMs) |
|
||||
[麦麦要当VTB](https://qm.qq.com/q/wGePTl1UyY) |
|
||||
## 💬 讨论与社区
|
||||
|
||||
为了维持技术交流和互帮互助的氛围,请不要在技术交流群讨论过多无关内容~
|
||||
我们欢迎所有对 MaiBot 感兴趣的朋友加入!
|
||||
|
||||
**聊天吹水群:**
|
||||
- [麦麦之闲聊群](https://qm.qq.com/q/JxvHZnxyec)
|
||||
| 类别 | 群组 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| **技术交流** | [麦麦脑电图](https://qm.qq.com/q/RzmCiRtHEW) | 技术交流/答疑 |
|
||||
| **技术交流** | [麦麦大脑磁共振](https://qm.qq.com/q/VQ3XZrWgMs) | 技术交流/答疑 |
|
||||
| **技术交流** | [麦麦要当VTB](https://qm.qq.com/q/wGePTl1UyY) | 技术交流/答疑 |
|
||||
| **闲聊吹水** | [麦麦之闲聊群](https://qm.qq.com/q/JxvHZnxyec) | 仅限闲聊,不答疑 |
|
||||
| **插件开发** | [插件开发群](https://qm.qq.com/q/1036092828) | 进阶开发与测试 |
|
||||
|
||||
麦麦相关闲聊群,此群仅用于聊天,提问部署/技术问题可能不会快速得到答案
|
||||
|
||||
**插件开发/测试版讨论群:**
|
||||
- [插件开发群](https://qm.qq.com/q/1036092828)
|
||||
|
||||
进阶内容,包括插件开发,测试版使用等等
|
||||
---
|
||||
|
||||
## 📚 文档
|
||||
|
||||
**部分内容可能更新不够及时,请注意版本对应**
|
||||
> [!NOTE]
|
||||
> 部分内容可能更新不够及时,请注意版本对应。
|
||||
|
||||
- [📚 核心 Wiki 文档](https://docs.mai-mai.org) - 项目最全面的文档中心,你可以了解麦麦有关的一切。
|
||||
- **[📚 核心 Wiki 文档](https://docs.mai-mai.org)**: 最全面的文档中心,了解麦麦的一切。
|
||||
|
||||
### 🧩 衍生项目
|
||||
|
||||
## 📚 衍生项目
|
||||
- **[MaiCraft](https://github.com/MaiM-with-u/Maicraft)**: 让麦麦陪你玩 Minecraft (早期开发中)。
|
||||
- **[MoFox_Bot](https://github.com/MoFox-Studio/MoFox-Core)**: 基于 MaiCore 0.10.0 的增强型 Fork,更稳定更有趣。
|
||||
|
||||
### MaiCraft(早期开发)
|
||||
[MaiCraft](https://github.com/MaiM-with-u/Maicraft)
|
||||
> 让麦麦具有玩MC能力的项目
|
||||
> 交流群:1058573197
|
||||
---
|
||||
|
||||
### MoFox_Bot
|
||||
[MoFox - 仓库地址](https://github.com/MoFox-Studio/MoFox-Core)
|
||||
> MoFox_Bot 是一个基于 MaiCore 0.10.0 snapshot.5 的增强型 fork 项目
|
||||
> 我们保留了原项目几乎所有核心功能,并在此基础上进行了深度优化与功能扩展,致力于打造一个更稳定、更智能、更具趣味性的 AI 智能体。
|
||||
|
||||
|
||||
|
||||
## 设计理念(原始时代的火花)
|
||||
## 💡 设计理念 (原始时代的火花)
|
||||
|
||||
> **千石可乐说:**
|
||||
> - 这个项目最初只是为了给牛牛 bot 添加一点额外的功能,但是功能越写越多,最后决定重写。其目的是为了创造一个活跃在 QQ 群聊的"生命体"。目的并不是为了写一个功能齐全的机器人,而是一个尽可能让人感知到真实的类人存在。
|
||||
@@ -119,41 +116,39 @@
|
||||
> - 如果人类真的需要一个 AI 来陪伴自己,并不是所有人都需要一个完美的,能解决所有问题的"helpful assistant",而是一个会犯错的,拥有自己感知和想法的"生命形式"。
|
||||
> - 代码会保持开源和开放,但个人希望 MaiMbot 的运行时数据保持封闭,尽量避免以显式命令来对其进行控制和调试。我认为一个你无法完全掌控的个体才更能让你感觉到它的自主性,而视其成为一个对话机器。
|
||||
> - SengokuCola~~纯编程外行,面向 cursor 编程,很多代码写得不好多多包涵~~已得到大脑升级。
|
||||
> *Code is open, but the soul is yours.*
|
||||
|
||||
---
|
||||
|
||||
## 🙋 贡献和致谢
|
||||
你可以阅读[开发文档](https://docs.mai-mai.org/develop/)来更好的了解麦麦!
|
||||
MaiCore 是一个开源项目,我们非常欢迎你的参与。你的贡献,无论是提交 bug 报告、功能需求还是代码 pr,都对项目非常宝贵。我们非常感谢你的支持!🎉
|
||||
但无序的讨论会降低沟通效率,进而影响问题的解决速度,因此在提交任何贡献前,请务必先阅读本项目的[贡献指南](docs-src/CONTRIBUTE.md)。(待补完)
|
||||
|
||||
### 贡献者
|
||||
欢迎参与贡献!请先阅读 [贡献指南](docs-src/CONTRIBUTE.md)。
|
||||
|
||||
感谢各位大佬!
|
||||
### 🌟 贡献者
|
||||
|
||||
<a href="https://github.com/MaiM-with-u/MaiBot/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=MaiM-with-u/MaiBot" />
|
||||
</a>
|
||||
|
||||
### 致谢
|
||||
### ❤️ 特别致谢
|
||||
|
||||
- [略nd](https://space.bilibili.com/1344099355): 为麦麦绘制人设。
|
||||
- [NapCat](https://github.com/NapNeko/NapCatQQ): 现代化的基于 NTQQ 的 Bot 协议端实现。
|
||||
- **[略nd](https://space.bilibili.com/1344099355)**: 🎨 为麦麦绘制精美人设。
|
||||
- **[NapCat](https://github.com/NapNeko/NapCatQQ)**: 🚀 现代化的基于 NTQQ 的 Bot 协议实现。
|
||||
|
||||
**也感谢每一位给麦麦发展提出宝贵意见与建议的用户,感谢陪伴麦麦走到现在的你们!**
|
||||
---
|
||||
|
||||
## 📌 注意事项
|
||||
|
||||
> [!WARNING]
|
||||
> 使用本项目前必须阅读和同意[用户协议](EULA.md)和[隐私协议](PRIVACY.md)。
|
||||
> 本应用生成内容来自人工智能模型,由 AI 生成,请仔细甄别,请勿用于违反法律的用途,AI 生成内容不代表本项目团队的观点和立场。
|
||||
|
||||
## 麦麦仓库状态
|
||||
## 📊 仓库状态
|
||||
|
||||

|
||||
|
||||
### Star 趋势
|
||||
|
||||
[](https://starchart.cc/MaiM-with-u/MaiBot)
|
||||
|
||||
## License
|
||||
---
|
||||
|
||||
GPL-3.0
|
||||
## 📌 注意事项 & License
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 使用前请阅读 [用户协议 (EULA)](EULA.md) 和 [隐私协议](PRIVACY.md)。AI 生成内容请仔细甄别。
|
||||
|
||||
**License**: GPL-3.0
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
# Changelog
|
||||
## [0.12.2] - 2025-1-11
|
||||
### 功能更改
|
||||
- 优化私聊wait逻辑
|
||||
- 超时时强制引用回复
|
||||
- 修复部分适配器断联问题
|
||||
- 修复表达反思配置未生效
|
||||
- 优化记忆检索逻辑
|
||||
- 更新readme
|
||||
|
||||
移除 enable_jargon_detection
|
||||
添加 global_memory_blacklist
|
||||
## [0.12.1] - 2025-12-31
|
||||
### 🌟 主要更新
|
||||
- 添加年度总结!可以在webui查看
|
||||
- 可选让llm判定引用回复
|
||||
- 表达方式优化!现在可以进行自动和手动评估,使其更精准
|
||||
- 回复和规划记录!webui可以查看每一条回复和plan的详情
|
||||
|
||||
### 细节功能更改
|
||||
- 优化间隔过长消息的显示
|
||||
- enable_jargon_detection
|
||||
- global_memory_blacklist。指定部分群聊不参与全局记忆
|
||||
- 移除utils_small模型,移除弃用的lpmm模型
|
||||
|
||||
## [0.12.0] - 2025-12-21
|
||||
### 🌟 重大更新
|
||||
|
||||
1
dashboard/.gitignore
vendored
Normal file
1
dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
dashboard/.prettierrc
Normal file
8
dashboard/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
661
dashboard/LICENSE
Normal file
661
dashboard/LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
377
dashboard/README.md
Normal file
377
dashboard/README.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# MaiBot Dashboard
|
||||
|
||||
> MaiBot 的现代化 Web 管理面板 - 基于 React 19 + TypeScript + Vite 构建
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://react.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://vitejs.dev/)
|
||||
[](https://tailwindcss.com/)
|
||||
|
||||
</div>
|
||||
|
||||
## 📖 项目简介
|
||||
|
||||
MaiBot Dashboard 是 MaiBot 聊天机器人的 Web 管理界面,提供了直观的配置管理、实时监控、插件管理、资源管理等功能。通过自动解析后端配置类,动态生成表单,实现了配置的可视化编辑。
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/main.png" alt="MaiBot Dashboard 界面预览" width="800" />
|
||||
</div>
|
||||
|
||||
### ✨ 核心特性
|
||||
|
||||
- 🎨 **现代化 UI** - 基于 shadcn/ui 组件库,支持亮色/暗色主题切换
|
||||
- ⚡ **高性能** - 使用 Vite 7.2 构建,React 19 最新特性
|
||||
- 🔐 **安全认证** - Token 认证机制,支持自定义和自动生成 Token
|
||||
- 📝 **智能配置** - 自动解析 Python dataclass,生成配置表单
|
||||
- 🎯 **类型安全** - 完整的 TypeScript 类型定义
|
||||
- 🔄 **实时更新** - WebSocket 实时日志流、配置自动保存
|
||||
- 📱 **响应式设计** - 完美适配桌面和移动设备
|
||||
- 💬 **本地对话** - 直接在 WebUI 与麦麦对话,无需外部平台
|
||||
|
||||
## 🎯 功能模块
|
||||
|
||||
### 📊 仪表盘(首页)
|
||||
- **实时统计** - 总请求数、Token 消耗、费用统计、在线时长
|
||||
- **模型统计** - 各模型的使用次数、费用、平均响应时间
|
||||
- **趋势图表** - 每小时请求量、Token 消耗、费用趋势折线图
|
||||
- **模型分布** - 饼图展示模型使用占比
|
||||
- **最近活动** - 实时刷新的请求活动列表
|
||||
|
||||
### 💬 本地聊天室
|
||||
- **WebSocket 实时通信** - 与麦麦直接对话
|
||||
- **消息历史** - 自动加载 SQLite 存储的历史消息
|
||||
- **连接状态** - 实时显示 WebSocket 连接状态
|
||||
- **自定义昵称** - 可自定义用户身份
|
||||
- **移动端适配** - 完整的响应式聊天界面
|
||||
|
||||
### ⚙️ 配置管理
|
||||
|
||||
#### 麦麦主程序配置
|
||||
- **分组展示** - 配置项按功能分组(基础设置、功能开关等)
|
||||
- **智能表单** - 根据配置类型自动生成对应控件
|
||||
- **自动保存** - 2秒防抖自动保存,无需手动操作
|
||||
- **一键重启** - 保存并重启麦麦,使配置生效
|
||||
|
||||
#### AI 模型厂商配置
|
||||
- **提供商管理** - 添加、编辑、删除 API 提供商
|
||||
- **模板选择** - 预设常用厂商模板(OpenAI、DeepSeek、硅基流动等)
|
||||
- **连接测试** - ⚡ 测试提供商连接状态和 API Key 有效性
|
||||
- **批量操作** - 批量删除、批量测试所有提供商
|
||||
- **搜索过滤** - 按名称、URL、类型快速筛选
|
||||
|
||||
#### 模型管理与分配
|
||||
- **模型列表** - 管理可用的模型配置
|
||||
- **使用状态** - 显示模型是否被任务使用
|
||||
- **任务分配** - 为不同功能分配模型(回复、工具调用、VLM 等)
|
||||
- **参数调整** - 温度、最大 Token 等参数配置
|
||||
- **新手引导** - 交互式引导教程
|
||||
|
||||
#### 适配器配置
|
||||
- **NapCat 配置** - 管理 QQ 机器人适配器
|
||||
- **Docker 支持** - 支持容器模式配置
|
||||
- **配置导入导出** - 跨环境迁移配置
|
||||
|
||||
### 📋 实时日志
|
||||
- **WebSocket 流式传输** - 实时接收后端日志
|
||||
- **虚拟滚动** - 高性能处理大量日志
|
||||
- **多级过滤** - 按日志级别(DEBUG/INFO/WARNING/ERROR)过滤
|
||||
- **模块过滤** - 按日志来源模块筛选
|
||||
- **时间范围** - 日期选择器筛选日志
|
||||
- **搜索高亮** - 关键字搜索并高亮显示
|
||||
- **字号调整** - 自定义日志显示字号和行间距
|
||||
- **日志导出** - 导出过滤后的日志
|
||||
|
||||
### 🔌 插件管理
|
||||
- **插件市场** - 浏览和搜索可用插件
|
||||
- **分类筛选** - 按类别、状态筛选插件
|
||||
- **一键安装** - 自动处理依赖并安装插件
|
||||
- **版本兼容** - 检查插件与 MaiBot 版本兼容性
|
||||
- **进度显示** - WebSocket 实时显示安装进度
|
||||
- **插件统计** - 下载量、更新时间等信息
|
||||
- **卸载更新** - 管理已安装插件
|
||||
|
||||
### 👤 人物关系管理
|
||||
- **人物列表** - 查看所有已知用户信息
|
||||
- **详情编辑** - 编辑用户昵称、备注等信息
|
||||
- **关系统计** - 查看消息数、互动频率等统计
|
||||
- **批量操作** - 批量删除用户记录
|
||||
|
||||
### 📦 资源管理
|
||||
|
||||
#### 表情包管理
|
||||
- **预览管理** - 图片/GIF 预览
|
||||
- **分类过滤** - 按注册状态、描述筛选
|
||||
- **编辑标签** - 修改表情包描述和属性
|
||||
- **批量禁用** - 启用/禁用表情包
|
||||
|
||||
#### 表达方式管理
|
||||
- **表达列表** - 查看麦麦学习的表达方式
|
||||
- **来源追踪** - 记录表达来源群组和用户
|
||||
- **编辑创建** - 手动添加或编辑表达
|
||||
|
||||
#### 知识图谱
|
||||
- **可视化展示** - ReactFlow 交互式图谱
|
||||
- **节点搜索** - 搜索实体和关系
|
||||
- **布局算法** - 自动布局优化
|
||||
- **详情查看** - 点击节点查看详细信息
|
||||
|
||||
### ⚙️ 系统设置
|
||||
- **主题切换** - 亮色/暗色/跟随系统
|
||||
- **动画控制** - 开启/关闭界面动画
|
||||
- **Token 管理** - 查看、复制、重新生成认证 Token
|
||||
- **版本信息** - 查看前端和后端版本
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 前端技术栈
|
||||
|
||||
```
|
||||
React 19.2.0 # UI 框架
|
||||
├── TypeScript 5.9 # 类型系统
|
||||
├── Vite 7.2 # 构建工具
|
||||
├── TanStack Router # 路由管理
|
||||
├── TanStack Virtual # 虚拟滚动
|
||||
├── Jotai # 状态管理
|
||||
├── Tailwind CSS 3.4 # 样式框架
|
||||
├── ReactFlow # 知识图谱可视化
|
||||
├── Recharts # 数据图表
|
||||
└── shadcn/ui # 组件库
|
||||
├── Radix UI # 无障碍组件
|
||||
└── lucide-react # 图标库
|
||||
```
|
||||
|
||||
### 后端集成
|
||||
|
||||
```
|
||||
FastAPI # Python 后端框架
|
||||
├── WebSocket # 实时日志、聊天
|
||||
├── config_schema.py # 配置架构生成器
|
||||
├── config_routes.py # 配置管理 API
|
||||
├── model_routes.py # 模型管理 API
|
||||
├── chat_routes.py # 本地聊天 API
|
||||
├── plugin_routes.py # 插件管理 API
|
||||
├── person_routes.py # 人物管理 API
|
||||
├── emoji_routes.py # 表情包管理 API
|
||||
├── expression_routes.py # 表达管理 API
|
||||
├── knowledge_routes.py # 知识图谱 API
|
||||
├── logs_routes.py # 日志 API
|
||||
└── tomlkit # TOML 文件处理
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
MaiBot-Dashboard/
|
||||
├── src/
|
||||
│ ├── components/ # 组件目录
|
||||
│ │ ├── ui/ # shadcn/ui 组件
|
||||
│ │ ├── layout.tsx # 布局组件(侧边栏+导航)
|
||||
│ │ ├── tour/ # 新手引导组件
|
||||
│ │ ├── plugin-stats.tsx # 插件统计组件
|
||||
│ │ ├── RestartingOverlay.tsx # 重启遮罩
|
||||
│ │ └── use-theme.tsx # 主题管理
|
||||
│ ├── routes/ # 路由页面
|
||||
│ │ ├── index.tsx # 仪表盘首页
|
||||
│ │ ├── auth.tsx # 登录页
|
||||
│ │ ├── chat.tsx # 本地聊天室
|
||||
│ │ ├── logs.tsx # 日志查看
|
||||
│ │ ├── plugins.tsx # 插件管理
|
||||
│ │ ├── person.tsx # 人物管理
|
||||
│ │ ├── settings.tsx # 系统设置
|
||||
│ │ ├── config/ # 配置管理页面
|
||||
│ │ │ ├── bot.tsx # 麦麦主程序配置
|
||||
│ │ │ ├── modelProvider.tsx # 模型提供商
|
||||
│ │ │ ├── model.tsx # 模型管理
|
||||
│ │ │ └── adapter.tsx # 适配器配置
|
||||
│ │ └── resource/ # 资源管理页面
|
||||
│ │ ├── emoji.tsx # 表情包管理
|
||||
│ │ ├── expression.tsx # 表达方式管理
|
||||
│ │ └── knowledge-graph.tsx # 知识图谱
|
||||
│ ├── lib/ # 工具库
|
||||
│ │ ├── config-api.ts # 配置 API 客户端
|
||||
│ │ ├── plugin-api.ts # 插件 API 客户端
|
||||
│ │ ├── person-api.ts # 人物 API 客户端
|
||||
│ │ ├── expression-api.ts # 表达 API 客户端
|
||||
│ │ ├── log-websocket.ts # 日志 WebSocket
|
||||
│ │ ├── fetch-with-auth.ts # 认证请求封装
|
||||
│ │ └── utils.ts # 通用工具函数
|
||||
│ ├── types/ # 类型定义
|
||||
│ │ ├── config-schema.ts # 配置架构类型
|
||||
│ │ ├── plugin.ts # 插件类型
|
||||
│ │ ├── person.ts # 人物类型
|
||||
│ │ └── expression.ts # 表达类型
|
||||
│ ├── hooks/ # React Hooks
|
||||
│ │ ├── use-auth.ts # 认证逻辑
|
||||
│ │ ├── use-animation.ts # 动画控制
|
||||
│ │ └── use-toast.ts # 消息提示
|
||||
│ ├── store/ # 全局状态
|
||||
│ │ └── auth.ts # 认证状态
|
||||
│ ├── router.tsx # 路由配置
|
||||
│ ├── main.tsx # 应用入口
|
||||
│ └── index.css # 全局样式
|
||||
├── public/ # 静态资源
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tailwind.config.js # Tailwind 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── package.json # 依赖管理
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- Bun >= 1.0.0 (推荐) 或 npm/yarn/pnpm
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 Bun(推荐)
|
||||
bun install
|
||||
|
||||
# 或使用 npm
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
# 启动开发服务器 (默认端口: 7999)
|
||||
bun run dev
|
||||
|
||||
# 或
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:7999 查看应用。
|
||||
|
||||
### 生产构建
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
bun run build
|
||||
|
||||
# 预览生产构建
|
||||
bun run preview
|
||||
```
|
||||
|
||||
构建产物会输出到 `dist/` 目录,由 MaiBot 后端静态服务。
|
||||
|
||||
### 代码格式化
|
||||
|
||||
```bash
|
||||
# 格式化代码
|
||||
bun run format
|
||||
```
|
||||
|
||||
## 🔧 开发配置
|
||||
|
||||
### Vite 代理配置
|
||||
|
||||
开发模式下,Vite 会将 API 请求代理到后端:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8001',
|
||||
changeOrigin: true,
|
||||
ws: true, // WebSocket 支持
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
开发环境默认使用 `http://localhost:7999`,生产环境使用相对路径。
|
||||
|
||||
## 📸 界面预览
|
||||
|
||||
### 仪表盘
|
||||
实时统计、模型使用分布、趋势图表
|
||||
|
||||
### 本地聊天
|
||||
直接与麦麦对话,消息实时同步
|
||||
|
||||
### 配置管理
|
||||
分组配置项,自动生成表单,自动保存
|
||||
|
||||
### 模型提供商
|
||||
一键测试连接状态,模板快速添加
|
||||
|
||||
### 日志查看
|
||||
实时日志流,多级过滤,虚拟滚动
|
||||
|
||||
## 📦 依赖说明
|
||||
|
||||
### 核心依赖
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| react | ^19.2.0 | UI 框架 |
|
||||
| react-dom | ^19.2.0 | React DOM 渲染 |
|
||||
| typescript | ~5.9.3 | 类型系统 |
|
||||
| vite | ^7.2.2 | 构建工具 |
|
||||
| @tanstack/react-router | ^1.136.1 | 路由管理 |
|
||||
| @tanstack/react-virtual | ^3.x | 虚拟滚动 |
|
||||
| jotai | ^2.15.1 | 状态管理 |
|
||||
| axios | ^1.13.2 | HTTP 客户端 |
|
||||
| recharts | ^2.x | 数据图表 |
|
||||
| reactflow | ^11.x | 知识图谱可视化 |
|
||||
| dagre | ^0.8.x | 图布局算法 |
|
||||
|
||||
### UI 组件库
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| @radix-ui/react-* | ^1.x | 无障碍组件基础 |
|
||||
| lucide-react | ^0.553.0 | 图标库 |
|
||||
| tailwindcss | ^3.4 | CSS 框架 |
|
||||
| class-variance-authority | ^0.7.1 | 类名管理 |
|
||||
| tailwind-merge | ^3.4.0 | Tailwind 类合并 |
|
||||
| date-fns | ^3.x | 日期处理 |
|
||||
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
### 代码规范
|
||||
|
||||
- 使用 TypeScript 严格模式
|
||||
- 遵循 ESLint 规则
|
||||
- 使用 Prettier 格式化代码
|
||||
- 组件使用函数式编写
|
||||
- 优先使用 Hooks
|
||||
- 响应式设计优先(移动端适配)
|
||||
|
||||
## 📄 开源协议
|
||||
|
||||
本项目基于 GPLv3 协议开源,详见 [LICENSE](./LICENSE) 文件。
|
||||
|
||||
## 👥 作者
|
||||
|
||||
**MotricSeven** - [GitHub](https://github.com/DrSmoothl)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [React](https://react.dev/) - UI 框架
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - 组件库
|
||||
- [Radix UI](https://www.radix-ui.com/) - 无障碍组件
|
||||
- [TanStack Router](https://tanstack.com/router) - 路由解决方案
|
||||
- [TanStack Virtual](https://tanstack.com/virtual) - 虚拟滚动
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架
|
||||
- [ReactFlow](https://reactflow.dev/) - 流程图/知识图谱
|
||||
- [Recharts](https://recharts.org/) - React 图表库
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
Made with ❤️ by MotricSeven and Mai-with-u
|
||||
</div>
|
||||
20
dashboard/components.json
Normal file
20
dashboard/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "src/components",
|
||||
"utils": "src/lib/utils",
|
||||
"ui": "src/components/ui",
|
||||
"lib": "src/lib",
|
||||
"hooks": "src/hooks"
|
||||
}
|
||||
}
|
||||
BIN
dashboard/docs/main.png
Normal file
BIN
dashboard/docs/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 413 KiB |
35
dashboard/eslint.config.js
Normal file
35
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
// 将所有 React Hooks 推荐规则降级为警告
|
||||
...Object.keys(reactHooks.configs.recommended.rules).reduce((acc, key) => {
|
||||
acc[key] = 'warn'
|
||||
return acc
|
||||
}, {}),
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
// 关闭或降级其他规则
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
},
|
||||
},
|
||||
)
|
||||
19
dashboard/index.html
Normal file
19
dashboard/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta http-equiv="content-language" content="zh-CN" />
|
||||
<!-- 防止搜索引擎索引 -->
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="googlebot" content="noindex, nofollow" />
|
||||
<meta name="bingbot" content="noindex, nofollow" />
|
||||
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MaiBot Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="notranslate"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
95
dashboard/package.json
Normal file
95
dashboard/package.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "maibot-dashboard",
|
||||
"private": true,
|
||||
"version": "0.11.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@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-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-router": "^1.140.0",
|
||||
"@tanstack/react-virtual": "^3.13.13",
|
||||
"@tanstack/router-devtools": "^1.140.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/dashboard": "^5.1.0",
|
||||
"@uppy/react": "^5.1.1",
|
||||
"@uppy/xhr-upload": "^5.1.1",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jotai": "^2.16.0",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.556.0",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "3.5.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"smol-toml": "^1.5.2",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vite": "^7.2.7"
|
||||
}
|
||||
}
|
||||
6
dashboard/postcss.config.js
Normal file
6
dashboard/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
dashboard/public/fonts/JetBrainsMono-Medium.ttf
Normal file
BIN
dashboard/public/fonts/JetBrainsMono-Medium.ttf
Normal file
Binary file not shown.
BIN
dashboard/public/maimai.ico
Normal file
BIN
dashboard/public/maimai.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
BIN
dashboard/src/assets/maimai.ico
Normal file
BIN
dashboard/src/assets/maimai.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
1
dashboard/src/assets/react.svg
Normal file
1
dashboard/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
126
dashboard/src/components/CodeEditor.tsx
Normal file
126
dashboard/src/components/CodeEditor.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
|
||||
|
||||
export type Language = 'python' | 'json' | 'toml' | 'text'
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string
|
||||
onChange?: (value: string) => void
|
||||
language?: Language
|
||||
readOnly?: boolean
|
||||
height?: string
|
||||
minHeight?: string
|
||||
maxHeight?: string
|
||||
placeholder?: string
|
||||
theme?: 'light' | 'dark'
|
||||
className?: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const languageExtensions: Record<Language, any[]> = {
|
||||
python: [python()],
|
||||
json: [json(), jsonParseLinter()],
|
||||
toml: [StreamLanguage.define(tomlMode)],
|
||||
text: [],
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
language = 'text',
|
||||
readOnly = false,
|
||||
height = '400px',
|
||||
minHeight,
|
||||
maxHeight,
|
||||
placeholder,
|
||||
theme = 'dark',
|
||||
className = '',
|
||||
}: CodeEditorProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border bg-muted animate-pulse ${className}`}
|
||||
style={{ height, minHeight, maxHeight }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const extensions = [
|
||||
...(languageExtensions[language] || []),
|
||||
EditorView.lineWrapping,
|
||||
// 应用 JetBrains Mono 字体
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
if (readOnly) {
|
||||
extensions.push(EditorView.editable.of(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={maxHeight}
|
||||
theme={theme === 'dark' ? oneDark : undefined}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightSpecialChars: true,
|
||||
history: true,
|
||||
foldGutter: true,
|
||||
drawSelection: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
syntaxHighlighting: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: true,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
defaultKeymap: true,
|
||||
searchKeymap: true,
|
||||
historyKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: true,
|
||||
lintKeymap: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeEditor
|
||||
525
dashboard/src/components/ListFieldEditor.tsx
Normal file
525
dashboard/src/components/ListFieldEditor.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* ListFieldEditor - 动态数组字段编辑器
|
||||
*
|
||||
* 支持功能:
|
||||
* - 字符串数组 (string[])
|
||||
* - 数字数组 (number[])
|
||||
* - 对象数组 (object[]) - 根据 item_fields 定义渲染
|
||||
* - 拖拽排序
|
||||
* - 动态增删项
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { GripVertical, Plus, Trash2, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
export interface ItemFieldDefinition {
|
||||
/** 字段类型: "string" | "number" | "boolean" | "select" */
|
||||
type: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
default?: unknown
|
||||
/** select 类型的选项 */
|
||||
choices?: unknown[]
|
||||
/** slider 类型的最小值 */
|
||||
min?: number
|
||||
/** slider 类型的最大值 */
|
||||
max?: number
|
||||
/** slider 类型的步进 */
|
||||
step?: number
|
||||
}
|
||||
|
||||
export interface ListFieldEditorProps {
|
||||
/** 当前值 */
|
||||
value: unknown[] | unknown
|
||||
/** 值变化回调 */
|
||||
onChange: (value: unknown[]) => void
|
||||
/** 数组元素类型: "string" | "number" | "object" */
|
||||
itemType?: string
|
||||
/** 当 itemType="object" 时的字段定义 */
|
||||
itemFields?: Record<string, ItemFieldDefinition>
|
||||
/** 最小元素数量 */
|
||||
minItems?: number
|
||||
/** 最大元素数量 */
|
||||
maxItems?: number
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 新项的占位符文字 */
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// ============ 可排序项组件 ============
|
||||
|
||||
interface SortableItemProps {
|
||||
id: string
|
||||
index: number
|
||||
itemType: string
|
||||
itemFields?: Record<string, ItemFieldDefinition>
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
canRemove: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function SortableItem({
|
||||
id,
|
||||
index,
|
||||
itemType,
|
||||
itemFields,
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
disabled,
|
||||
canRemove,
|
||||
placeholder,
|
||||
}: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-start gap-2 group',
|
||||
isDragging && 'opacity-50 z-50'
|
||||
)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-shrink-0 p-2 cursor-grab active:cursor-grabbing',
|
||||
'text-muted-foreground hover:text-foreground transition-colors',
|
||||
'opacity-0 group-hover:opacity-100 focus:opacity-100',
|
||||
disabled && 'cursor-not-allowed opacity-30'
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{itemType === 'object' && itemFields ? (
|
||||
<ObjectItemEditor
|
||||
value={value as Record<string, unknown>}
|
||||
onChange={onChange}
|
||||
fields={itemFields}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : itemType === 'number' ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={value as number ?? ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
placeholder={placeholder ?? `第 ${index + 1} 项`}
|
||||
disabled={disabled}
|
||||
className="font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={value as string ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? `第 ${index + 1} 项`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
disabled={disabled || !canRemove}
|
||||
className={cn(
|
||||
'flex-shrink-0 text-muted-foreground hover:text-destructive',
|
||||
'opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 对象项编辑器 ============
|
||||
|
||||
interface ObjectItemEditorProps {
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
fields: Record<string, ItemFieldDefinition>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function ObjectItemEditor({
|
||||
value,
|
||||
onChange,
|
||||
fields,
|
||||
disabled,
|
||||
}: ObjectItemEditorProps) {
|
||||
const handleFieldChange = useCallback(
|
||||
(fieldName: string, fieldValue: unknown) => {
|
||||
onChange({
|
||||
...value,
|
||||
[fieldName]: fieldValue,
|
||||
})
|
||||
},
|
||||
[value, onChange]
|
||||
)
|
||||
|
||||
const renderField = (fieldName: string, fieldDef: ItemFieldDefinition) => {
|
||||
const fieldValue = value?.[fieldName]
|
||||
|
||||
// boolean / switch
|
||||
if (fieldDef.type === 'boolean' || fieldDef.type === 'switch') {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={Boolean(fieldValue ?? fieldDef.default)}
|
||||
onCheckedChange={(checked) => handleFieldChange(fieldName, checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// slider (number with min/max)
|
||||
if (fieldDef.type === 'slider' || (fieldDef.type === 'number' && fieldDef.min != null && fieldDef.max != null)) {
|
||||
const numValue = (fieldValue as number) ?? (fieldDef.default as number) ?? fieldDef.min ?? 0
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">{numValue}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[numValue]}
|
||||
onValueChange={(v) => handleFieldChange(fieldName, v[0])}
|
||||
min={fieldDef.min ?? 0}
|
||||
max={fieldDef.max ?? 100}
|
||||
step={fieldDef.step ?? 1}
|
||||
disabled={disabled}
|
||||
className="py-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// select
|
||||
if (fieldDef.type === 'select' && fieldDef.choices) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(fieldValue ?? fieldDef.default ?? '')}
|
||||
onValueChange={(v) => handleFieldChange(fieldName, v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder={fieldDef.placeholder ?? '请选择'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldDef.choices.map((choice) => (
|
||||
<SelectItem key={String(choice)} value={String(choice)}>
|
||||
{String(choice)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// number
|
||||
if (fieldDef.type === 'number') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={(fieldValue as number) ?? fieldDef.default ?? ''}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(fieldName, parseFloat(e.target.value) || 0)
|
||||
}
|
||||
placeholder={fieldDef.placeholder}
|
||||
disabled={disabled}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// string (default)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={(fieldValue as string) ?? fieldDef.default ?? ''}
|
||||
onChange={(e) => handleFieldChange(fieldName, e.target.value)}
|
||||
placeholder={fieldDef.placeholder}
|
||||
disabled={disabled}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-3 space-y-2 bg-muted/30">
|
||||
{Object.entries(fields).map(([fieldName, fieldDef]) => (
|
||||
<div key={fieldName}>
|
||||
{renderField(fieldName, fieldDef)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
export function ListFieldEditor({
|
||||
value,
|
||||
onChange,
|
||||
itemType = 'string',
|
||||
itemFields,
|
||||
minItems,
|
||||
maxItems,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: ListFieldEditorProps) {
|
||||
// 确保 value 是数组
|
||||
const items: unknown[] = useMemo(() => {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
// 尝试解析逗号分隔的字符串
|
||||
return value.split(',').map((s: string) => s.trim())
|
||||
}
|
||||
return []
|
||||
}, [value])
|
||||
|
||||
// 为每个项生成稳定的 ID
|
||||
const [itemIds] = useState(() => new Map<number, string>())
|
||||
const getItemId = useCallback(
|
||||
(index: number) => {
|
||||
if (!itemIds.has(index)) {
|
||||
itemIds.set(index, `item-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`)
|
||||
}
|
||||
return itemIds.get(index)!
|
||||
},
|
||||
[itemIds]
|
||||
)
|
||||
|
||||
// 同步 itemIds
|
||||
const sortableIds = useMemo(() => {
|
||||
// 清理多余的 ID
|
||||
const newIds: string[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
newIds.push(getItemId(i))
|
||||
}
|
||||
return newIds
|
||||
}, [items.length, getItemId])
|
||||
|
||||
// DnD 传感器配置
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
// 拖拽结束处理
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = sortableIds.indexOf(active.id as string)
|
||||
const newIndex = sortableIds.indexOf(over.id as string)
|
||||
const newItems = arrayMove(items, oldIndex, newIndex)
|
||||
onChange(newItems)
|
||||
}
|
||||
},
|
||||
[items, sortableIds, onChange]
|
||||
)
|
||||
|
||||
// 添加新项
|
||||
const handleAddItem = useCallback(() => {
|
||||
if (maxItems != null && items.length >= maxItems) return
|
||||
|
||||
let newItem: unknown
|
||||
if (itemType === 'object' && itemFields) {
|
||||
// 创建包含默认值的对象
|
||||
newItem = Object.fromEntries(
|
||||
Object.entries(itemFields).map(([k, v]) => [k, v.default ?? ''])
|
||||
)
|
||||
} else if (itemType === 'number') {
|
||||
newItem = 0
|
||||
} else {
|
||||
newItem = ''
|
||||
}
|
||||
|
||||
onChange([...items, newItem])
|
||||
}, [items, maxItems, itemType, itemFields, onChange])
|
||||
|
||||
// 修改项
|
||||
const handleItemChange = useCallback(
|
||||
(index: number, newValue: unknown) => {
|
||||
const newItems = [...items]
|
||||
newItems[index] = newValue
|
||||
onChange(newItems)
|
||||
},
|
||||
[items, onChange]
|
||||
)
|
||||
|
||||
// 删除项
|
||||
const handleRemoveItem = useCallback(
|
||||
(index: number) => {
|
||||
if (minItems != null && items.length <= minItems) return
|
||||
const newItems = items.filter((_: unknown, i: number) => i !== index)
|
||||
// 清理 itemIds 映射
|
||||
itemIds.delete(index)
|
||||
onChange(newItems)
|
||||
},
|
||||
[items, minItems, itemIds, onChange]
|
||||
)
|
||||
|
||||
const canAdd = maxItems == null || items.length < maxItems
|
||||
const canRemove = minItems == null || items.length > minItems
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 列表项 */}
|
||||
{items.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center border border-dashed rounded-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>暂无数据,点击下方按钮添加</span>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{items.map((item: unknown, index: number) => (
|
||||
<SortableItem
|
||||
key={sortableIds[index]}
|
||||
id={sortableIds[index]}
|
||||
index={index}
|
||||
itemType={itemType}
|
||||
itemFields={itemFields}
|
||||
value={item}
|
||||
onChange={(newValue) => handleItemChange(index, newValue)}
|
||||
onRemove={() => handleRemoveItem(index)}
|
||||
disabled={disabled}
|
||||
canRemove={canRemove}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* 添加按钮 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddItem}
|
||||
disabled={disabled || !canAdd}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加项目
|
||||
{maxItems !== undefined && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({items.length}/{maxItems})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 限制提示 */}
|
||||
{(minItems != null || maxItems != null) && (minItems !== null || maxItems !== null) && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{minItems != null && maxItems != null
|
||||
? `允许 ${minItems} - ${maxItems} 项`
|
||||
: minItems != null
|
||||
? `至少 ${minItems} 项`
|
||||
: `最多 ${maxItems} 项`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListFieldEditor
|
||||
189
dashboard/src/components/RestartingOverlay.legacy.tsx
Normal file
189
dashboard/src/components/RestartingOverlay.legacy.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
|
||||
/**
|
||||
* @deprecated 请使用新的 RestartOverlay 组件
|
||||
* import { RestartOverlay } from '@/components/restart-overlay'
|
||||
*/
|
||||
interface RestartingOverlayProps {
|
||||
onRestartComplete?: () => void
|
||||
onRestartFailed?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 请使用新的 RestartOverlay 组件
|
||||
* import { RestartOverlay } from '@/components/restart-overlay'
|
||||
*/
|
||||
export function RestartingOverlay({ onRestartComplete, onRestartFailed }: RestartingOverlayProps) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [status, setStatus] = useState<'restarting' | 'checking' | 'success' | 'failed'>('restarting')
|
||||
const [elapsedTime, setElapsedTime] = useState(0)
|
||||
const [checkAttempts, setCheckAttempts] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
// 进度条动画
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 90) return prev
|
||||
return prev + 1
|
||||
})
|
||||
}, 200)
|
||||
|
||||
// 计时器
|
||||
const timerInterval = setInterval(() => {
|
||||
setElapsedTime((prev) => prev + 1)
|
||||
}, 1000)
|
||||
|
||||
// 等待3秒后开始检查状态(给后端重启时间)
|
||||
const initialDelay = setTimeout(() => {
|
||||
setStatus('checking')
|
||||
startHealthCheck()
|
||||
}, 3000)
|
||||
|
||||
return () => {
|
||||
clearInterval(progressInterval)
|
||||
clearInterval(timerInterval)
|
||||
clearTimeout(initialDelay)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const startHealthCheck = () => {
|
||||
const maxAttempts = 60 // 最多尝试60次(约2分钟)
|
||||
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
setCheckAttempts((prev) => prev + 1)
|
||||
|
||||
const response = await fetch('/api/webui/system/status', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(3000), // 3秒超时
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// 重启成功
|
||||
setProgress(100)
|
||||
setStatus('success')
|
||||
setTimeout(() => {
|
||||
onRestartComplete?.()
|
||||
}, 1500)
|
||||
} else {
|
||||
throw new Error('Status check failed')
|
||||
}
|
||||
} catch {
|
||||
// 继续尝试
|
||||
if (checkAttempts < maxAttempts) {
|
||||
setTimeout(checkHealth, 2000) // 2秒后重试
|
||||
} else {
|
||||
// 超过最大尝试次数
|
||||
setStatus('failed')
|
||||
onRestartFailed?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkHealth()
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background/95 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="max-w-md w-full mx-4 space-y-8">
|
||||
{/* 图标和状态 */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{status === 'restarting' && (
|
||||
<>
|
||||
<Loader2 className="h-16 w-16 text-primary animate-spin" />
|
||||
<h2 className="text-2xl font-bold">正在重启麦麦</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
请稍候,麦麦正在重启中...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="h-16 w-16 text-primary animate-spin" />
|
||||
<h2 className="text-2xl font-bold">检查服务状态</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
等待服务恢复... (尝试 {checkAttempts}/60)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
<h2 className="text-2xl font-bold">重启成功</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
正在跳转到登录页面...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'failed' && (
|
||||
<>
|
||||
<AlertCircle className="h-16 w-16 text-destructive" />
|
||||
<h2 className="text-2xl font-bold">重启超时</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
服务未能在预期时间内恢复,请手动检查或刷新页面
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{status !== 'failed' && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{progress}%</span>
|
||||
<span>已用时: {formatTime(elapsedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status === 'restarting' && '🔄 配置已保存,正在重启主程序...'}
|
||||
{status === 'checking' && '⏳ 正在等待服务恢复,请勿关闭页面...'}
|
||||
{status === 'success' && '✅ 配置已生效,服务运行正常'}
|
||||
{status === 'failed' && '⚠️ 如果长时间无响应,请尝试手动重启'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 失败时的操作按钮 */}
|
||||
{status === 'failed' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatus('checking')
|
||||
setCheckAttempts(0)
|
||||
startHealthCheck()
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||
>
|
||||
重试检测
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
dashboard/src/components/animation-provider.tsx
Normal file
54
dashboard/src/components/animation-provider.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AnimationContext } from '@/lib/animation-context'
|
||||
|
||||
type AnimationProviderProps = {
|
||||
children: ReactNode
|
||||
defaultEnabled?: boolean
|
||||
defaultWavesEnabled?: boolean
|
||||
storageKey?: string
|
||||
wavesStorageKey?: string
|
||||
}
|
||||
|
||||
export function AnimationProvider({
|
||||
children,
|
||||
defaultEnabled = true,
|
||||
defaultWavesEnabled = true,
|
||||
storageKey = 'enable-animations',
|
||||
wavesStorageKey = 'enable-waves-background',
|
||||
}: AnimationProviderProps) {
|
||||
const [enableAnimations, setEnableAnimations] = useState<boolean>(() => {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
return stored !== null ? stored === 'true' : defaultEnabled
|
||||
})
|
||||
|
||||
const [enableWavesBackground, setEnableWavesBackground] = useState<boolean>(() => {
|
||||
const stored = localStorage.getItem(wavesStorageKey)
|
||||
return stored !== null ? stored === 'true' : defaultWavesEnabled
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
|
||||
if (enableAnimations) {
|
||||
root.classList.remove('no-animations')
|
||||
} else {
|
||||
root.classList.add('no-animations')
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKey, String(enableAnimations))
|
||||
}, [enableAnimations, storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(wavesStorageKey, String(enableWavesBackground))
|
||||
}, [enableWavesBackground, wavesStorageKey])
|
||||
|
||||
const value = {
|
||||
enableAnimations,
|
||||
setEnableAnimations,
|
||||
enableWavesBackground,
|
||||
setEnableWavesBackground,
|
||||
}
|
||||
|
||||
return <AnimationContext.Provider value={value}>{children}</AnimationContext.Provider>
|
||||
}
|
||||
101
dashboard/src/components/back-to-top.tsx
Normal file
101
dashboard/src/components/back-to-top.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function BackToTop() {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const scrollerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// 简单的启发式:如果是主要滚动容器(通常高度较大)
|
||||
// 我们假设页面中主要的滚动区域是高度最大的那个,或者就是当前触发滚动的这个
|
||||
// 只要它有足够的滚动空间
|
||||
if (target.scrollHeight > target.clientHeight + 100) {
|
||||
scrollerRef.current = target
|
||||
|
||||
const scrollTop = target.scrollTop
|
||||
const height = target.scrollHeight - target.clientHeight
|
||||
const scrolled = height > 0 ? (scrollTop / height) * 100 : 0
|
||||
|
||||
setProgress(scrolled)
|
||||
setVisible(scrollTop > 300)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用捕获阶段监听所有滚动事件,因为 scroll 事件不冒泡
|
||||
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll, { capture: true })
|
||||
}, [])
|
||||
|
||||
const scrollToTop = () => {
|
||||
scrollerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// SVG 环形进度条参数
|
||||
const radius = 18
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const strokeDashoffset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-24 right-8 z-50 transition-all duration-500 ease-in-out transform",
|
||||
visible ? "translate-x-0 opacity-100" : "translate-x-32 opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"relative h-12 w-12 rounded-full shadow-xl",
|
||||
"bg-background/80 backdrop-blur-md border-border/50",
|
||||
"hover:bg-accent hover:scale-110 hover:shadow-2xl hover:border-primary/50",
|
||||
"transition-all duration-300",
|
||||
"group"
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
{/* 进度环背景 */}
|
||||
<svg className="absolute inset-0 h-full w-full -rotate-90 transform p-1" viewBox="0 0 44 44">
|
||||
<circle
|
||||
className="text-muted-foreground/10"
|
||||
strokeWidth="3"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="22"
|
||||
cy="22"
|
||||
/>
|
||||
{/* 进度环 */}
|
||||
<circle
|
||||
className="text-primary transition-all duration-100 ease-out"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="22"
|
||||
cy="22"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* 图标 */}
|
||||
<ArrowUp
|
||||
className="h-5 w-5 text-primary transition-transform duration-300 group-hover:-translate-y-1 group-hover:scale-110"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
|
||||
{/* 内部发光效果 (仅在 dark 模式下明显) */}
|
||||
<div className="absolute inset-0 rounded-full bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
dashboard/src/components/emoji-thumbnail.tsx
Normal file
123
dashboard/src/components/emoji-thumbnail.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 表情包缩略图组件
|
||||
*
|
||||
* 特性:
|
||||
* - 自动处理 202 响应(缩略图生成中)
|
||||
* - 显示 Skeleton 占位符
|
||||
* - 自动重试加载
|
||||
* - 加载失败显示占位图标
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmojiThumbnailProps {
|
||||
src: string
|
||||
alt?: string
|
||||
className?: string
|
||||
/** 最大重试次数 */
|
||||
maxRetries?: number
|
||||
/** 重试间隔(毫秒) */
|
||||
retryInterval?: number
|
||||
}
|
||||
|
||||
type LoadingState = 'loading' | 'loaded' | 'generating' | 'error'
|
||||
|
||||
export function EmojiThumbnail({
|
||||
src,
|
||||
alt = '表情包',
|
||||
className,
|
||||
maxRetries = 5,
|
||||
retryInterval = 1500,
|
||||
}: EmojiThumbnailProps) {
|
||||
const [state, setState] = useState<LoadingState>('loading')
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
||||
const [currentSrc, setCurrentSrc] = useState(src)
|
||||
|
||||
// 当 src 变化时重置状态
|
||||
if (src !== currentSrc) {
|
||||
setState('loading')
|
||||
setRetryCount(0)
|
||||
setImageSrc(null)
|
||||
setCurrentSrc(src)
|
||||
}
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(src, {
|
||||
credentials: 'include', // 携带 Cookie
|
||||
})
|
||||
|
||||
if (response.status === 202) {
|
||||
// 缩略图正在生成中
|
||||
setState('generating')
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
// 延迟后重试
|
||||
setTimeout(() => {
|
||||
setRetryCount(prev => prev + 1)
|
||||
}, retryInterval)
|
||||
} else {
|
||||
// 超过最大重试次数,显示错误
|
||||
setState('error')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
// 成功获取图片
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
setImageSrc(objectUrl)
|
||||
setState('loaded')
|
||||
} catch (error) {
|
||||
console.error('加载缩略图失败:', error)
|
||||
setState('error')
|
||||
}
|
||||
}, [src, retryCount, maxRetries, retryInterval])
|
||||
|
||||
useEffect(() => {
|
||||
loadImage()
|
||||
}, [loadImage])
|
||||
|
||||
// 清理 Object URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc)
|
||||
}
|
||||
}
|
||||
}, [imageSrc])
|
||||
|
||||
// 加载中或生成中显示 Skeleton
|
||||
if (state === 'loading' || state === 'generating') {
|
||||
return (
|
||||
<Skeleton className={cn('w-full h-full', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
// 加载失败显示占位图标
|
||||
if (state === 'error' || !imageSrc) {
|
||||
return (
|
||||
<div className={cn('w-full h-full flex items-center justify-center bg-muted', className)}>
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 加载成功显示图片
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className={cn('w-full h-full object-contain', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
307
dashboard/src/components/error-boundary.tsx
Normal file
307
dashboard/src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { Component } from 'react'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { AlertTriangle, RefreshCw, Home, ChevronDown, ChevronUp, Copy, Check, Bug } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
errorInfo: ErrorInfo | null
|
||||
}
|
||||
|
||||
// 解析堆栈信息为结构化数据
|
||||
interface StackFrame {
|
||||
functionName: string
|
||||
fileName: string
|
||||
lineNumber: string
|
||||
columnNumber: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
function parseStackTrace(stack: string): StackFrame[] {
|
||||
const lines = stack.split('\n').slice(1) // 跳过第一行(错误消息)
|
||||
const frames: StackFrame[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('at ')) continue
|
||||
|
||||
// 匹配格式: at functionName (fileName:line:column) 或 at fileName:line:column
|
||||
const match = trimmed.match(/at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/)
|
||||
if (match) {
|
||||
frames.push({
|
||||
functionName: match[1] || '<anonymous>',
|
||||
fileName: match[2],
|
||||
lineNumber: match[3],
|
||||
columnNumber: match[4],
|
||||
raw: trimmed,
|
||||
})
|
||||
} else {
|
||||
frames.push({
|
||||
functionName: '<unknown>',
|
||||
fileName: '',
|
||||
lineNumber: '',
|
||||
columnNumber: '',
|
||||
raw: trimmed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
// 错误详情展示组件(函数组件,用于使用 hooks)
|
||||
function ErrorDetails({ error, errorInfo }: { error: Error; errorInfo: ErrorInfo | null }) {
|
||||
const [isStackOpen, setIsStackOpen] = useState(true)
|
||||
const [isComponentStackOpen, setIsComponentStackOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const stackFrames = error.stack ? parseStackTrace(error.stack) : []
|
||||
|
||||
const copyErrorInfo = async () => {
|
||||
const errorText = `
|
||||
Error: ${error.name}
|
||||
Message: ${error.message}
|
||||
|
||||
Stack Trace:
|
||||
${error.stack || 'No stack trace available'}
|
||||
|
||||
Component Stack:
|
||||
${errorInfo?.componentStack || 'No component stack available'}
|
||||
|
||||
URL: ${window.location.href}
|
||||
User Agent: ${navigator.userAgent}
|
||||
Time: ${new Date().toISOString()}
|
||||
`.trim()
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(errorText)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 错误消息 */}
|
||||
<Alert variant="destructive" className="border-red-500/50 bg-red-500/10">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="font-mono text-sm">
|
||||
<span className="font-semibold">{error.name}:</span> {error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 堆栈跟踪 */}
|
||||
{stackFrames.length > 0 && (
|
||||
<Collapsible open={isStackOpen} onOpenChange={setIsStackOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between p-3 h-auto">
|
||||
<span className="font-semibold text-sm flex items-center gap-2">
|
||||
<Bug className="h-4 w-4" />
|
||||
Stack Trace ({stackFrames.length} frames)
|
||||
</span>
|
||||
{isStackOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="h-[280px] rounded-md border bg-muted/30">
|
||||
<div className="p-3 space-y-1">
|
||||
{stackFrames.map((frame, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="font-mono text-xs p-2 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-6 text-right flex-shrink-0">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-primary font-medium">
|
||||
{frame.functionName}
|
||||
</span>
|
||||
{frame.fileName && (
|
||||
<div className="text-muted-foreground mt-0.5 break-all">
|
||||
{frame.fileName}
|
||||
{frame.lineNumber && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
:{frame.lineNumber}:{frame.columnNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* 组件堆栈 */}
|
||||
{errorInfo?.componentStack && (
|
||||
<Collapsible open={isComponentStackOpen} onOpenChange={setIsComponentStackOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between p-3 h-auto">
|
||||
<span className="font-semibold text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Component Stack
|
||||
</span>
|
||||
{isComponentStackOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="h-[200px] rounded-md border bg-muted/30">
|
||||
<pre className="p-3 font-mono text-xs whitespace-pre-wrap text-muted-foreground">
|
||||
{errorInfo.componentStack}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* 复制按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyErrorInfo}
|
||||
className="w-full"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4 text-green-500" />
|
||||
已复制到剪贴板
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制错误信息
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 错误回退 UI
|
||||
function ErrorFallback({
|
||||
error,
|
||||
errorInfo,
|
||||
}: {
|
||||
error: Error
|
||||
errorInfo: ErrorInfo | null
|
||||
}) {
|
||||
const handleGoHome = () => {
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-2xl shadow-lg">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
||||
<AlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">页面出现了问题</CardTitle>
|
||||
<CardDescription className="text-base mt-2">
|
||||
应用程序遇到了意外错误。您可以尝试刷新页面或返回首页。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<ErrorDetails error={error} errorInfo={errorInfo} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2">
|
||||
<Button onClick={handleRefresh} className="flex-1">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button onClick={handleGoHome} variant="outline" className="flex-1">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<p className="text-xs text-center text-muted-foreground pt-2">
|
||||
如果问题持续存在,请将错误信息复制并反馈给开发者
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 错误边界类组件
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
this.setState({ errorInfo })
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorFallback
|
||||
error={this.state.error}
|
||||
errorInfo={this.state.errorInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
// 路由级别的错误边界组件(用于 TanStack Router)
|
||||
export function RouteErrorBoundary({ error }: { error: Error }) {
|
||||
return (
|
||||
<ErrorFallback
|
||||
error={error}
|
||||
errorInfo={null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
1597
dashboard/src/components/expression-reviewer.tsx
Normal file
1597
dashboard/src/components/expression-reviewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
59
dashboard/src/components/http-warning-banner.tsx
Normal file
59
dashboard/src/components/http-warning-banner.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* HTTP 警告横幅组件
|
||||
* 当用户通过 HTTP 访问时显示安全警告
|
||||
*/
|
||||
export function HttpWarningBanner() {
|
||||
// 直接计算初始状态,避免 effect 中调用 setState
|
||||
const isHttp = window.location.protocol === 'http:'
|
||||
const hostname = window.location.hostname.toLowerCase()
|
||||
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
||||
const dismissed = sessionStorage.getItem('http-warning-dismissed') === 'true'
|
||||
|
||||
// 本地访问(localhost/127.0.0.1)不显示警告
|
||||
const [isVisible, setIsVisible] = useState(isHttp && !isLocalhost && !dismissed)
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true)
|
||||
setIsVisible(false)
|
||||
sessionStorage.setItem('http-warning-dismissed', 'true')
|
||||
}
|
||||
|
||||
if (!isVisible || isDismissed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative bg-amber-500/10 border-b border-amber-500/20 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
<span className="font-semibold">安全警告:</span>
|
||||
您正在使用 <strong>HTTP</strong> 访问 MaiBot WebUI
|
||||
</p>
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200 mt-1">
|
||||
如果这是公网服务器,您的数据(包括 Token、聊天记录等)可能在传输过程中被窃取。强烈建议使用 HTTPS 访问或仅在本地网络使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDismiss}
|
||||
className="h-8 w-8 text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 flex-shrink-0"
|
||||
aria-label="关闭警告"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
dashboard/src/components/index.ts
Normal file
13
dashboard/src/components/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { CodeEditor } from './CodeEditor'
|
||||
export type { Language } from './CodeEditor'
|
||||
|
||||
// 重启遮罩层
|
||||
export { RestartOverlay } from './restart-overlay'
|
||||
// 兼容旧版本
|
||||
export { RestartingOverlay } from './RestartingOverlay.legacy'
|
||||
|
||||
// 列表编辑器
|
||||
export { ListFieldEditor } from './ListFieldEditor'
|
||||
|
||||
// Markdown 渲染器
|
||||
export { MarkdownRenderer } from './markdown-renderer'
|
||||
409
dashboard/src/components/layout.tsx
Normal file
409
dashboard/src/components/layout.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Menu, Moon, Sun, ChevronLeft, Home, Settings, LogOut, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, Package, BookOpen, Search, Sliders, Network, Hash, LayoutGrid, Database, Activity, PieChart } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router'
|
||||
import { useTheme, toggleThemeWithTransition } from './use-theme'
|
||||
import { useAuthGuard } from '@/hooks/use-auth'
|
||||
import { logout } from '@/lib/fetch-with-auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { HttpWarningBanner } from '@/components/http-warning-banner'
|
||||
import { BackToTop } from '@/components/back-to-top'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatVersion } from '@/lib/version'
|
||||
import type { ReactNode, ComponentType } from 'react'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
icon: ComponentType<LucideProps>
|
||||
label: string
|
||||
path: string
|
||||
tourId?: string
|
||||
}
|
||||
|
||||
interface MenuSection {
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const { checking } = useAuthGuard() // 检查认证状态
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态
|
||||
const { theme, setTheme } = useTheme()
|
||||
const matchRoute = useMatchRoute()
|
||||
|
||||
// 侧边栏状态变化时,延迟启用/禁用 tooltip
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
// 侧边栏展开时,立即禁用 tooltip
|
||||
setTooltipsEnabled(false)
|
||||
} else {
|
||||
// 侧边栏收起时,等待动画完成后再启用 tooltip
|
||||
const timer = setTimeout(() => {
|
||||
setTooltipsEnabled(true)
|
||||
}, 350) // 稍大于 CSS transition duration (300ms)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [sidebarOpen])
|
||||
|
||||
// 搜索快捷键监听(Cmd/Ctrl + K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// 认证检查中,显示加载状态
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-muted-foreground">正在验证登录状态...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 菜单项配置 - 分块结构
|
||||
const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: '概览',
|
||||
items: [
|
||||
{ icon: Home, label: '首页', path: '/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '麦麦配置编辑',
|
||||
items: [
|
||||
{ icon: FileText, label: '麦麦主程序配置', path: '/config/bot' },
|
||||
{ icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
|
||||
{ icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' },
|
||||
{ icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '麦麦资源管理',
|
||||
items: [
|
||||
{ icon: Smile, label: '表情包管理', path: '/resource/emoji' },
|
||||
{ icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' },
|
||||
{ icon: Hash, label: '黑话管理', path: '/resource/jargon' },
|
||||
{ icon: UserCircle, label: '人物信息管理', path: '/resource/person' },
|
||||
{ icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' },
|
||||
{ icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '扩展与监控',
|
||||
items: [
|
||||
{ icon: Package, label: '插件市场', path: '/plugins' },
|
||||
{ icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' },
|
||||
{ icon: Sliders, label: '插件配置', path: '/plugin-config' },
|
||||
{ icon: FileSearch, label: '日志查看器', path: '/logs' },
|
||||
{ icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' },
|
||||
{ icon: MessageSquare, label: '本地聊天室', path: '/chat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: [
|
||||
{ icon: Settings, label: '系统设置', path: '/settings' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 获取实际应用的主题(处理 system 情况)
|
||||
const getActualTheme = () => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
const actualTheme = getActualTheme()
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r bg-card transition-all duration-300 lg:relative lg:z-0',
|
||||
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
||||
'w-64 lg:w-auto',
|
||||
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<div className="flex h-16 items-center border-b px-4">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center flex-1 transition-all overflow-hidden',
|
||||
// 移动端始终完整显示,桌面端根据 sidebarOpen 切换
|
||||
'lg:flex-1',
|
||||
!sidebarOpen && 'lg:flex-none lg:w-8'
|
||||
)}
|
||||
>
|
||||
{/* 移动端始终显示完整 Logo,桌面端根据 sidebarOpen 切换 */}
|
||||
<div className={cn(
|
||||
"flex items-baseline gap-2",
|
||||
!sidebarOpen && "lg:hidden"
|
||||
)}>
|
||||
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
|
||||
<span className="text-xs text-primary/60 whitespace-nowrap">
|
||||
{formatVersion()}
|
||||
</span>
|
||||
</div>
|
||||
{/* 折叠时的 Logo - 仅桌面端显示 */}
|
||||
{!sidebarOpen && (
|
||||
<span className="hidden lg:block font-bold text-primary-gradient text-2xl">M</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className={cn(
|
||||
"flex-1 overflow-x-hidden",
|
||||
!sidebarOpen && "lg:w-16"
|
||||
)}>
|
||||
<nav className={cn(
|
||||
"p-4",
|
||||
!sidebarOpen && "lg:p-2 lg:w-16"
|
||||
)}>
|
||||
<ul className={cn(
|
||||
// 移动端始终使用正常间距,桌面端根据 sidebarOpen 切换
|
||||
"space-y-6",
|
||||
!sidebarOpen && "lg:space-y-3 lg:w-full"
|
||||
)}>
|
||||
{menuSections.map((section, sectionIndex) => (
|
||||
<li key={section.title}>
|
||||
{/* 块标题 - 移动端始终可见,桌面端根据 sidebarOpen 切换 */}
|
||||
<div className={cn(
|
||||
"px-3 h-[1.25rem]",
|
||||
// 移动端始终显示,桌面端根据状态切换
|
||||
"mb-2",
|
||||
!sidebarOpen && "lg:mb-1 lg:invisible"
|
||||
)}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap">
|
||||
{section.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 分割线 - 仅在桌面端折叠时显示 */}
|
||||
{!sidebarOpen && sectionIndex > 0 && (
|
||||
<div className="hidden lg:block mb-2 border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* 菜单项列表 */}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => {
|
||||
const isActive = matchRoute({ to: item.path })
|
||||
const Icon = item.icon
|
||||
|
||||
const menuItemContent = (
|
||||
<>
|
||||
{/* 左侧高亮条 */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-opacity duration-300" />
|
||||
)}
|
||||
<div className={cn(
|
||||
'flex items-center transition-all duration-300',
|
||||
sidebarOpen ? 'gap-3' : 'gap-3 lg:gap-0'
|
||||
)}>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
isActive && 'text-primary'
|
||||
)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-sm font-medium whitespace-nowrap transition-all duration-300',
|
||||
isActive && 'font-semibold',
|
||||
sidebarOpen
|
||||
? 'opacity-100 max-w-[200px]'
|
||||
: 'opacity-100 max-w-[200px] lg:opacity-0 lg:max-w-0 lg:overflow-hidden'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<li key={item.path} className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={item.path}
|
||||
data-tour={item.tourId}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-lg py-2 transition-all duration-300',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
sidebarOpen ? 'px-3' : 'px-3 lg:px-0 lg:justify-center lg:w-12 lg:mx-auto'
|
||||
)}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{menuItemContent}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
{tooltipsEnabled && (
|
||||
<TooltipContent side="right" className="hidden lg:block">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* HTTP 安全警告横幅 */}
|
||||
<HttpWarningBanner />
|
||||
|
||||
{/* Topbar */}
|
||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="rounded-lg p-2 hover:bg-accent lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 桌面端侧边栏收起/展开按钮 */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="hidden rounded-lg p-2 hover:bg-accent lg:block"
|
||||
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 年度总结入口 */}
|
||||
<Link to="/annual-report">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
|
||||
title="查看年度总结"
|
||||
>
|
||||
<PieChart className="h-4 w-4 text-pink-500" />
|
||||
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">2025 年度总结</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
|
||||
>
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">搜索...</span>
|
||||
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<span className="text-xs">⌘</span>K
|
||||
</Kbd>
|
||||
</button>
|
||||
|
||||
{/* 搜索对话框 */}
|
||||
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
|
||||
{/* 麦麦文档链接 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
|
||||
className="gap-2"
|
||||
title="查看麦麦文档"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">麦麦文档</span>
|
||||
</Button>
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
|
||||
toggleThemeWithTransition(newTheme, setTheme, e)
|
||||
}}
|
||||
className="rounded-lg p-2 hover:bg-accent"
|
||||
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
title="登出系统"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">登出</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-hidden bg-background">{children}</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<BackToTop />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
134
dashboard/src/components/markdown-renderer.tsx
Normal file
134
dashboard/src/components/markdown-renderer.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
// 自定义代码块样式
|
||||
code({ inline, className, children, ...props }: ComponentPropsWithoutRef<'code'> & { inline?: boolean }) {
|
||||
return inline ? (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className={`${className} block bg-muted p-4 rounded-lg overflow-x-auto`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
// 自定义表格样式
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse border border-border" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
th({ children, ...props }) {
|
||||
return (
|
||||
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold" {...props}>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
td({ children, ...props }) {
|
||||
return (
|
||||
<td className="border border-border px-4 py-2" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
// 自定义链接样式
|
||||
a({ children, ...props }) {
|
||||
return (
|
||||
<a className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
// 自定义引用块样式
|
||||
blockquote({ children, ...props }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-primary pl-4 italic text-muted-foreground" {...props}>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
// 自定义标题样式
|
||||
h1({ children, ...props }) {
|
||||
return (
|
||||
<h1 className="text-3xl font-bold mt-6 mb-4" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2({ children, ...props }) {
|
||||
return (
|
||||
<h2 className="text-2xl font-bold mt-5 mb-3" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
h3({ children, ...props }) {
|
||||
return (
|
||||
<h3 className="text-xl font-bold mt-4 mb-2" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h4({ children, ...props }) {
|
||||
return (
|
||||
<h4 className="text-lg font-semibold mt-3 mb-2" {...props}>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
// 自定义列表样式
|
||||
ul({ children, ...props }) {
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-1 my-2" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
ol({ children, ...props }) {
|
||||
return (
|
||||
<ol className="list-decimal list-inside space-y-1 my-2" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
},
|
||||
// 自定义段落样式
|
||||
p({ children, ...props }) {
|
||||
return (
|
||||
<p className="my-2 leading-relaxed" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
},
|
||||
// 自定义分隔线样式
|
||||
hr({ ...props }) {
|
||||
return <hr className="my-4 border-border" {...props} />
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
302
dashboard/src/components/plugin-stats.tsx
Normal file
302
dashboard/src/components/plugin-stats.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 插件统计组件
|
||||
* 显示点赞、点踩、评分和下载量
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ThumbsUp, ThumbsDown, Star, Download } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
getPluginStats,
|
||||
likePlugin,
|
||||
dislikePlugin,
|
||||
ratePlugin,
|
||||
type PluginStatsData,
|
||||
} from '@/lib/plugin-stats'
|
||||
|
||||
interface PluginStatsProps {
|
||||
pluginId: string
|
||||
compact?: boolean // 紧凑模式(只显示数字)
|
||||
}
|
||||
|
||||
export function PluginStats({ pluginId, compact = false }: PluginStatsProps) {
|
||||
const [stats, setStats] = useState<PluginStatsData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [userRating, setUserRating] = useState(0)
|
||||
const [userComment, setUserComment] = useState('')
|
||||
const [isRatingDialogOpen, setIsRatingDialogOpen] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
setLoading(true)
|
||||
const data = await getPluginStats(pluginId)
|
||||
if (data) {
|
||||
setStats(data)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pluginId])
|
||||
|
||||
// 处理点赞
|
||||
const handleLike = async () => {
|
||||
const result = await likePlugin(pluginId)
|
||||
|
||||
if (result.success) {
|
||||
toast({ title: '已点赞', description: '感谢你的支持!' })
|
||||
loadStats() // 重新加载统计数据
|
||||
} else {
|
||||
toast({
|
||||
title: '点赞失败',
|
||||
description: result.error || '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理点踩
|
||||
const handleDislike = async () => {
|
||||
const result = await dislikePlugin(pluginId)
|
||||
|
||||
if (result.success) {
|
||||
toast({ title: '已反馈', description: '感谢你的反馈!' })
|
||||
loadStats()
|
||||
} else {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: result.error || '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 提交评分
|
||||
const handleSubmitRating = async () => {
|
||||
if (userRating === 0) {
|
||||
toast({
|
||||
title: '请选择评分',
|
||||
description: '至少选择 1 颗星',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await ratePlugin(pluginId, userRating, userComment || undefined)
|
||||
|
||||
if (result.success) {
|
||||
toast({ title: '评分成功', description: '感谢你的评价!' })
|
||||
setIsRatingDialogOpen(false)
|
||||
setUserRating(0)
|
||||
setUserComment('')
|
||||
loadStats()
|
||||
} else {
|
||||
toast({
|
||||
title: '评分失败',
|
||||
description: result.error || '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4" />
|
||||
<span>-</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 紧凑模式
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1" title={`下载量: ${stats.downloads.toLocaleString()}`}>
|
||||
<Download className="h-4 w-4" />
|
||||
<span>{stats.downloads.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" title={`评分: ${stats.rating.toFixed(1)} (${stats.rating_count} 条评价)`}>
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
<span>{stats.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" title={`点赞数: ${stats.likes}`}>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
<span>{stats.likes}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 完整模式
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 统计数字 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
|
||||
<Download className="h-5 w-5 text-muted-foreground mb-1" />
|
||||
<span className="text-2xl font-bold">{stats.downloads.toLocaleString()}</span>
|
||||
<span className="text-xs text-muted-foreground">下载量</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
|
||||
<Star className="h-5 w-5 text-yellow-400 mb-1 fill-yellow-400" />
|
||||
<span className="text-2xl font-bold">{stats.rating.toFixed(1)}</span>
|
||||
<span className="text-xs text-muted-foreground">{stats.rating_count} 条评价</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
|
||||
<ThumbsUp className="h-5 w-5 text-green-500 mb-1" />
|
||||
<span className="text-2xl font-bold">{stats.likes}</span>
|
||||
<span className="text-xs text-muted-foreground">点赞</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
|
||||
<ThumbsDown className="h-5 w-5 text-red-500 mb-1" />
|
||||
<span className="text-2xl font-bold">{stats.dislikes}</span>
|
||||
<span className="text-xs text-muted-foreground">点踩</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLike}>
|
||||
<ThumbsUp className="h-4 w-4 mr-1" />
|
||||
点赞
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleDislike}>
|
||||
<ThumbsDown className="h-4 w-4 mr-1" />
|
||||
点踩
|
||||
</Button>
|
||||
|
||||
<Dialog open={isRatingDialogOpen} onOpenChange={setIsRatingDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" size="sm">
|
||||
<Star className="h-4 w-4 mr-1" />
|
||||
评分
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>为插件评分</DialogTitle>
|
||||
<DialogDescription>分享你的使用体验,帮助其他用户</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 星级评分 */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => setUserRating(star)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
star <= userRating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:text-yellow-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{userRating === 0 && '点击星星进行评分'}
|
||||
{userRating === 1 && '很差'}
|
||||
{userRating === 2 && '一般'}
|
||||
{userRating === 3 && '还行'}
|
||||
{userRating === 4 && '不错'}
|
||||
{userRating === 5 && '非常好'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 评论 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">评论(可选)</label>
|
||||
<Textarea
|
||||
value={userComment}
|
||||
onChange={(e) => setUserComment(e.target.value)}
|
||||
placeholder="分享你的使用体验..."
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{userComment.length} / 500
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsRatingDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmitRating} disabled={userRating === 0}>
|
||||
提交评分
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* 最近评价 */}
|
||||
{stats.recent_ratings && stats.recent_ratings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">最近评价</h4>
|
||||
<div className="space-y-3">
|
||||
{stats.recent_ratings.map((rating, index) => (
|
||||
<div key={index} className="p-3 rounded-lg border bg-muted/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`h-3 w-3 ${
|
||||
star <= rating.rating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(rating.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{rating.comment && (
|
||||
<p className="text-sm text-muted-foreground">{rating.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
412
dashboard/src/components/restart-overlay.tsx
Normal file
412
dashboard/src/components/restart-overlay.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 重启遮罩层组件
|
||||
*
|
||||
* 用于显示重启进度和状态,阻止用户操作
|
||||
*
|
||||
* 使用方式 1: 配合 RestartProvider(推荐)
|
||||
* <RestartProvider>
|
||||
* <App />
|
||||
* <RestartOverlay />
|
||||
* </RestartProvider>
|
||||
*
|
||||
* 使用方式 2: 独立使用
|
||||
* <RestartOverlay
|
||||
* visible={true}
|
||||
* onComplete={() => navigate('/auth')}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRestart, type RestartStatus, type RestartContextValue } from '@/lib/restart-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Hook 用于安全获取 restart context
|
||||
function useSafeRestart(): RestartContextValue | null {
|
||||
try {
|
||||
return useRestart()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
interface RestartOverlayProps {
|
||||
/** 是否可见(仅独立模式使用) */
|
||||
visible?: boolean
|
||||
/** 重启完成回调 */
|
||||
onComplete?: () => void
|
||||
/** 重启失败回调 */
|
||||
onFailed?: () => void
|
||||
/** 自定义标题 */
|
||||
title?: string
|
||||
/** 自定义描述 */
|
||||
description?: string
|
||||
/** 是否显示背景动画 */
|
||||
showAnimation?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ============ 状态配置 ============
|
||||
|
||||
interface StatusConfig {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
tip: string
|
||||
}
|
||||
|
||||
const getStatusConfig = (
|
||||
status: RestartStatus,
|
||||
checkAttempts: number,
|
||||
maxAttempts: number,
|
||||
customTitle?: string,
|
||||
customDescription?: string
|
||||
): StatusConfig => {
|
||||
const configs: Record<RestartStatus, StatusConfig> = {
|
||||
idle: {
|
||||
icon: null,
|
||||
title: '',
|
||||
description: '',
|
||||
tip: '',
|
||||
},
|
||||
requesting: {
|
||||
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
|
||||
title: customTitle ?? '准备重启',
|
||||
description: customDescription ?? '正在发送重启请求...',
|
||||
tip: '🔄 正在准备重启麦麦...',
|
||||
},
|
||||
restarting: {
|
||||
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
|
||||
title: customTitle ?? '正在重启麦麦',
|
||||
description: customDescription ?? '请稍候,麦麦正在重启中...',
|
||||
tip: '🔄 配置已保存,正在重启主程序...',
|
||||
},
|
||||
checking: {
|
||||
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
|
||||
title: '检查服务状态',
|
||||
description: `等待服务恢复... (${checkAttempts}/${maxAttempts})`,
|
||||
tip: '⏳ 正在等待服务恢复,请勿关闭页面...',
|
||||
},
|
||||
success: {
|
||||
icon: <CheckCircle2 className="h-16 w-16 text-green-500" />,
|
||||
title: '重启成功',
|
||||
description: '正在跳转到登录页面...',
|
||||
tip: '✅ 配置已生效,服务运行正常',
|
||||
},
|
||||
failed: {
|
||||
icon: <AlertCircle className="h-16 w-16 text-destructive" />,
|
||||
title: '重启超时',
|
||||
description: '服务未能在预期时间内恢复',
|
||||
tip: '⚠️ 如果长时间无响应,请尝试手动重启',
|
||||
},
|
||||
}
|
||||
return configs[status]
|
||||
}
|
||||
|
||||
// ============ 主组件(配合 Provider) ============
|
||||
|
||||
export function RestartOverlay({
|
||||
visible,
|
||||
onComplete,
|
||||
onFailed,
|
||||
title,
|
||||
description,
|
||||
showAnimation = true,
|
||||
className,
|
||||
}: RestartOverlayProps) {
|
||||
// 尝试使用 context(可能不存在)
|
||||
const contextValue = useSafeRestart()
|
||||
|
||||
// 如果有 context,使用 context 状态;否则使用 props
|
||||
const isVisible = contextValue ? contextValue.isRestarting : visible
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
if (contextValue) {
|
||||
return (
|
||||
<RestartOverlayContent
|
||||
state={contextValue.state}
|
||||
onRetry={contextValue.retryHealthCheck}
|
||||
onComplete={onComplete}
|
||||
onFailed={onFailed}
|
||||
title={title}
|
||||
description={description}
|
||||
showAnimation={showAnimation}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立模式
|
||||
return (
|
||||
<StandaloneRestartOverlay
|
||||
onComplete={onComplete}
|
||||
onFailed={onFailed}
|
||||
title={title}
|
||||
description={description}
|
||||
showAnimation={showAnimation}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 内容组件 ============
|
||||
|
||||
interface RestartOverlayContentProps {
|
||||
state: {
|
||||
status: RestartStatus
|
||||
progress: number
|
||||
elapsedTime: number
|
||||
checkAttempts: number
|
||||
maxAttempts: number
|
||||
error?: string
|
||||
}
|
||||
onRetry: () => void
|
||||
onComplete?: () => void
|
||||
onFailed?: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
showAnimation?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function RestartOverlayContent({
|
||||
state,
|
||||
onRetry,
|
||||
onComplete,
|
||||
onFailed,
|
||||
title,
|
||||
description,
|
||||
showAnimation,
|
||||
className,
|
||||
}: RestartOverlayContentProps) {
|
||||
const { status, progress, elapsedTime, checkAttempts, maxAttempts } = state
|
||||
|
||||
// 回调处理
|
||||
useEffect(() => {
|
||||
if (status === 'success' && onComplete) {
|
||||
onComplete()
|
||||
} else if (status === 'failed' && onFailed) {
|
||||
onFailed()
|
||||
}
|
||||
}, [status, onComplete, onFailed])
|
||||
|
||||
const config = getStatusConfig(
|
||||
status,
|
||||
checkAttempts,
|
||||
maxAttempts,
|
||||
title,
|
||||
description
|
||||
)
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-background/95 backdrop-blur-sm z-50 flex items-center justify-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 背景动画 */}
|
||||
{showAnimation && <BackgroundAnimation />}
|
||||
|
||||
<div className="max-w-md w-full mx-4 space-y-8 relative z-10">
|
||||
{/* 图标和状态 */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
{config.icon}
|
||||
{/* 脉冲动画 */}
|
||||
{(status === 'restarting' || status === 'checking') && (
|
||||
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">{config.title}</h2>
|
||||
<p className="text-muted-foreground text-center">{config.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{status !== 'failed' && status !== 'idle' && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{progress}%</span>
|
||||
<span>已用时: {formatTime(elapsedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">{config.tip}</p>
|
||||
</div>
|
||||
|
||||
{/* 失败时的操作按钮 */}
|
||||
{status === 'failed' && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button onClick={onRetry} variant="secondary" className="flex-1">
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
重试检测
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 独立模式组件 ============
|
||||
|
||||
interface StandaloneRestartOverlayProps {
|
||||
onComplete?: () => void
|
||||
onFailed?: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
showAnimation?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function StandaloneRestartOverlay({
|
||||
onComplete,
|
||||
onFailed,
|
||||
title,
|
||||
description,
|
||||
showAnimation,
|
||||
className,
|
||||
}: StandaloneRestartOverlayProps) {
|
||||
const [state, setState] = useState({
|
||||
status: 'restarting' as RestartStatus,
|
||||
progress: 0,
|
||||
elapsedTime: 0,
|
||||
checkAttempts: 0,
|
||||
maxAttempts: 60,
|
||||
})
|
||||
|
||||
const startHealthCheck = useCallback(() => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 60
|
||||
|
||||
const check = async () => {
|
||||
attempts++
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: 'checking',
|
||||
checkAttempts: attempts,
|
||||
}))
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/webui/system/status', {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setState((prev) => ({ ...prev, status: 'success', progress: 100 }))
|
||||
setTimeout(() => {
|
||||
onComplete?.()
|
||||
window.location.href = '/auth'
|
||||
}, 1500)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 继续重试
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
setState((prev) => ({ ...prev, status: 'failed' }))
|
||||
onFailed?.()
|
||||
} else {
|
||||
setTimeout(check, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
}, [onComplete, onFailed])
|
||||
|
||||
useEffect(() => {
|
||||
// 进度条动画
|
||||
const progressInterval = setInterval(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
progress: prev.progress >= 90 ? prev.progress : prev.progress + 1,
|
||||
}))
|
||||
}, 200)
|
||||
|
||||
// 计时器
|
||||
const timerInterval = setInterval(() => {
|
||||
setState((prev) => ({ ...prev, elapsedTime: prev.elapsedTime + 1 }))
|
||||
}, 1000)
|
||||
|
||||
// 3秒后开始健康检查
|
||||
const initialDelay = setTimeout(() => {
|
||||
startHealthCheck()
|
||||
}, 3000)
|
||||
|
||||
return () => {
|
||||
clearInterval(progressInterval)
|
||||
clearInterval(timerInterval)
|
||||
clearTimeout(initialDelay)
|
||||
}
|
||||
}, [startHealthCheck])
|
||||
|
||||
return (
|
||||
<RestartOverlayContent
|
||||
state={state}
|
||||
onRetry={startHealthCheck}
|
||||
onComplete={onComplete}
|
||||
onFailed={onFailed}
|
||||
title={title}
|
||||
description={description}
|
||||
showAnimation={showAnimation}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 背景动画 ============
|
||||
|
||||
function BackgroundAnimation() {
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{/* 渐变圆环 */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px]">
|
||||
<div className="absolute inset-0 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite]" />
|
||||
<div className="absolute inset-8 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite_0.5s]" />
|
||||
<div className="absolute inset-16 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite_1s]" />
|
||||
</div>
|
||||
|
||||
{/* 浮动粒子 */}
|
||||
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-primary/20 rounded-full animate-bounce" />
|
||||
<div className="absolute top-3/4 right-1/4 w-3 h-3 bg-primary/15 rounded-full animate-bounce delay-150" />
|
||||
<div className="absolute top-1/2 right-1/3 w-2 h-2 bg-primary/20 rounded-full animate-bounce delay-300" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 导出旧组件(兼容性) ============
|
||||
|
||||
// 如需使用旧版组件,请直接导入:
|
||||
// import { RestartingOverlay } from '@/components/RestartingOverlay.legacy'
|
||||
237
dashboard/src/components/search-dialog.tsx
Normal file
237
dashboard/src/components/search-dialog.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Search, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, BarChart3, Package, Settings, Home, Hash } from 'lucide-react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface SearchItem {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
category: string
|
||||
}
|
||||
|
||||
const searchItems: SearchItem[] = [
|
||||
{
|
||||
icon: Home,
|
||||
title: '首页',
|
||||
description: '查看仪表板概览',
|
||||
path: '/',
|
||||
category: '概览',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: '麦麦主程序配置',
|
||||
description: '配置麦麦的核心设置',
|
||||
path: '/config/bot',
|
||||
category: '配置',
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: '麦麦模型提供商配置',
|
||||
description: '配置模型提供商',
|
||||
path: '/config/modelProvider',
|
||||
category: '配置',
|
||||
},
|
||||
{
|
||||
icon: Boxes,
|
||||
title: '麦麦模型配置',
|
||||
description: '配置模型参数',
|
||||
path: '/config/model',
|
||||
category: '配置',
|
||||
},
|
||||
{
|
||||
icon: Smile,
|
||||
title: '表情包管理',
|
||||
description: '管理麦麦的表情包',
|
||||
path: '/resource/emoji',
|
||||
category: '资源',
|
||||
},
|
||||
{
|
||||
icon: MessageSquare,
|
||||
title: '表达方式管理',
|
||||
description: '管理麦麦的表达方式',
|
||||
path: '/resource/expression',
|
||||
category: '资源',
|
||||
},
|
||||
{
|
||||
icon: UserCircle,
|
||||
title: '人物信息管理',
|
||||
description: '管理人物信息',
|
||||
path: '/resource/person',
|
||||
category: '资源',
|
||||
},
|
||||
{
|
||||
icon: Hash,
|
||||
title: '黑话管理',
|
||||
description: '管理麦麦学习到的黑话和俚语',
|
||||
path: '/resource/jargon',
|
||||
category: '资源',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: '统计信息',
|
||||
description: '查看使用统计',
|
||||
path: '/statistics',
|
||||
category: '监控',
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: '插件市场',
|
||||
description: '浏览和安装插件',
|
||||
path: '/plugins',
|
||||
category: '扩展',
|
||||
},
|
||||
{
|
||||
icon: FileSearch,
|
||||
title: '日志查看器',
|
||||
description: '查看系统日志',
|
||||
path: '/logs',
|
||||
category: '监控',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: '系统设置',
|
||||
description: '配置系统参数',
|
||||
path: '/settings',
|
||||
category: '系统',
|
||||
},
|
||||
]
|
||||
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 过滤搜索结果
|
||||
const filteredItems = searchItems.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// 导航到页面
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
navigate({ to: path })
|
||||
onOpenChange(false)
|
||||
// 在导航后重置状态
|
||||
setSearchQuery('')
|
||||
setSelectedIndex(0)
|
||||
}, [navigate, onOpenChange])
|
||||
|
||||
// 键盘导航
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredItems.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length)
|
||||
} else if (e.key === 'Enter' && filteredItems[selectedIndex]) {
|
||||
e.preventDefault()
|
||||
handleNavigate(filteredItems[selectedIndex].path)
|
||||
}
|
||||
},
|
||||
[filteredItems, selectedIndex, handleNavigate]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl p-0 gap-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-0">
|
||||
<DialogTitle className="sr-only">搜索</DialogTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索页面..."
|
||||
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-t">
|
||||
<ScrollArea className="h-[400px]">
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => handleNavigate(item.path)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-left transition-colors',
|
||||
index === selectedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1 bg-muted rounded">
|
||||
{item.category}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery ? '未找到匹配的页面' : '输入关键词开始搜索'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">↑</kbd>
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">↓</kbd>
|
||||
导航
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Enter</kbd>
|
||||
选择
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Esc</kbd>
|
||||
关闭
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
684
dashboard/src/components/share-pack-dialog.tsx
Normal file
684
dashboard/src/components/share-pack-dialog.tsx
Normal file
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* 分享 Pack 对话框
|
||||
*
|
||||
* 允许用户将当前配置导出并分享到 Pack 市场
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Package,
|
||||
Share2,
|
||||
Server,
|
||||
Layers,
|
||||
ListChecks,
|
||||
Tag,
|
||||
Loader2,
|
||||
Check,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import {
|
||||
createPack,
|
||||
exportCurrentConfigAsPack,
|
||||
type PackProvider,
|
||||
type PackModel,
|
||||
type PackTaskConfigs,
|
||||
} from '@/lib/pack-api'
|
||||
|
||||
// 任务类型名称映射
|
||||
const TASK_TYPE_NAMES: Record<string, string> = {
|
||||
utils: '通用工具',
|
||||
utils_small: '轻量工具',
|
||||
tool_use: '工具调用',
|
||||
replyer: '回复生成',
|
||||
planner: '规划推理',
|
||||
vlm: '视觉模型',
|
||||
voice: '语音处理',
|
||||
embedding: '向量嵌入',
|
||||
lpmm_entity_extract: '实体提取',
|
||||
lpmm_rdf_build: 'RDF构建',
|
||||
lpmm_qa: '问答模型',
|
||||
}
|
||||
|
||||
// 预设标签
|
||||
const PRESET_TAGS = [
|
||||
'官方推荐',
|
||||
'性价比',
|
||||
'高性能',
|
||||
'免费模型',
|
||||
'国内可用',
|
||||
'海外模型',
|
||||
'OpenAI',
|
||||
'Claude',
|
||||
'Gemini',
|
||||
'国产模型',
|
||||
'多模态',
|
||||
'轻量级',
|
||||
]
|
||||
|
||||
interface SharePackDialogProps {
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SharePackDialog({ trigger }: SharePackDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 配置数据
|
||||
const [providers, setProviders] = useState<PackProvider[]>([])
|
||||
const [models, setModels] = useState<PackModel[]>([])
|
||||
const [taskConfig, setTaskConfig] = useState<PackTaskConfigs>({})
|
||||
|
||||
// 选择状态
|
||||
const [selectedProviders, setSelectedProviders] = useState<Set<string>>(new Set())
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set())
|
||||
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set())
|
||||
|
||||
// Pack 信息
|
||||
const [packName, setPackName] = useState('')
|
||||
const [packDescription, setPackDescription] = useState('')
|
||||
const [packAuthor, setPackAuthor] = useState('')
|
||||
const [packTags, setPackTags] = useState<string[]>([])
|
||||
|
||||
// 加载当前配置
|
||||
useEffect(() => {
|
||||
if (open && step === 1) {
|
||||
loadCurrentConfig()
|
||||
}
|
||||
}, [open, step])
|
||||
|
||||
const loadCurrentConfig = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const config = await exportCurrentConfigAsPack({
|
||||
name: '',
|
||||
description: '',
|
||||
author: '',
|
||||
})
|
||||
|
||||
setProviders(config.providers)
|
||||
setModels(config.models)
|
||||
setTaskConfig(config.task_config)
|
||||
|
||||
// 默认全选
|
||||
setSelectedProviders(new Set(config.providers.map(p => p.name)))
|
||||
setSelectedModels(new Set(config.models.map(m => m.name)))
|
||||
setSelectedTasks(new Set(Object.keys(config.task_config)))
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
toast({ title: '加载当前配置失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换选择
|
||||
const toggleProvider = (name: string) => {
|
||||
const newSet = new Set(selectedProviders)
|
||||
const newModels = new Set(selectedModels)
|
||||
const newTasks = new Set(selectedTasks)
|
||||
|
||||
if (newSet.has(name)) {
|
||||
// 取消选择提供商
|
||||
newSet.delete(name)
|
||||
|
||||
// 取消选择该提供商下的所有模型
|
||||
const providerModels = models.filter(m => m.api_provider === name)
|
||||
providerModels.forEach(m => newModels.delete(m.name))
|
||||
|
||||
// 检查任务配置,如果任务使用的所有模型都被取消选择了,也取消选择该任务
|
||||
Object.entries(taskConfig).forEach(([key, config]) => {
|
||||
if (config.model_list) {
|
||||
const hasSelectedModel = config.model_list.some((modelName: string) => newModels.has(modelName))
|
||||
if (!hasSelectedModel) {
|
||||
newTasks.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 选择提供商
|
||||
newSet.add(name)
|
||||
|
||||
// 自动选择该提供商下的所有模型
|
||||
const providerModels = models.filter(m => m.api_provider === name)
|
||||
providerModels.forEach(m => newModels.add(m.name))
|
||||
|
||||
// 自动选择使用这些模型的任务
|
||||
Object.entries(taskConfig).forEach(([key, config]) => {
|
||||
if (config.model_list) {
|
||||
const hasProviderModel = config.model_list.some((modelName: string) => {
|
||||
const model = models.find(m => m.name === modelName)
|
||||
return model && model.api_provider === name
|
||||
})
|
||||
if (hasProviderModel) {
|
||||
newTasks.add(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setSelectedProviders(newSet)
|
||||
setSelectedModels(newModels)
|
||||
setSelectedTasks(newTasks)
|
||||
}
|
||||
|
||||
const toggleModel = (name: string) => {
|
||||
const newModels = new Set(selectedModels)
|
||||
const newTasks = new Set(selectedTasks)
|
||||
|
||||
if (newModels.has(name)) {
|
||||
// 取消选择模型
|
||||
newModels.delete(name)
|
||||
|
||||
// 检查任务配置,如果任务使用的所有模型都被取消选择了,也取消选择该任务
|
||||
Object.entries(taskConfig).forEach(([key, config]) => {
|
||||
if (config.model_list) {
|
||||
const hasSelectedModel = config.model_list.some((modelName: string) => newModels.has(modelName))
|
||||
if (!hasSelectedModel) {
|
||||
newTasks.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 选择模型
|
||||
newModels.add(name)
|
||||
|
||||
// 自动选择使用这个模型的任务
|
||||
Object.entries(taskConfig).forEach(([key, config]) => {
|
||||
if (config.model_list && config.model_list.includes(name)) {
|
||||
newTasks.add(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setSelectedModels(newModels)
|
||||
setSelectedTasks(newTasks)
|
||||
}
|
||||
|
||||
const toggleTask = (key: string) => {
|
||||
const newSet = new Set(selectedTasks)
|
||||
if (newSet.has(key)) {
|
||||
newSet.delete(key)
|
||||
} else {
|
||||
newSet.add(key)
|
||||
}
|
||||
setSelectedTasks(newSet)
|
||||
}
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
if (packTags.includes(tag)) {
|
||||
setPackTags(packTags.filter(t => t !== tag))
|
||||
} else if (packTags.length < 5) {
|
||||
setPackTags([...packTags, tag])
|
||||
} else {
|
||||
toast({ title: '最多选择 5 个标签', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const selectAllProviders = () => {
|
||||
if (selectedProviders.size === providers.length) {
|
||||
setSelectedProviders(new Set())
|
||||
} else {
|
||||
setSelectedProviders(new Set(providers.map(p => p.name)))
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllModels = () => {
|
||||
if (selectedModels.size === models.length) {
|
||||
setSelectedModels(new Set())
|
||||
} else {
|
||||
setSelectedModels(new Set(models.map(m => m.name)))
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllTasks = () => {
|
||||
const taskKeys = Object.keys(taskConfig)
|
||||
if (selectedTasks.size === taskKeys.length) {
|
||||
setSelectedTasks(new Set())
|
||||
} else {
|
||||
setSelectedTasks(new Set(taskKeys))
|
||||
}
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
// 验证
|
||||
if (!packName.trim()) {
|
||||
toast({ title: '请输入模板名称', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!packDescription.trim()) {
|
||||
toast({ title: '请输入模板描述', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!packAuthor.trim()) {
|
||||
toast({ title: '请输入作者名称', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0) {
|
||||
toast({ title: '请至少选择一项配置', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 过滤选中的配置
|
||||
const selectedProviderConfigs = providers.filter(p => selectedProviders.has(p.name))
|
||||
const selectedModelConfigs = models.filter(m => selectedModels.has(m.name))
|
||||
const selectedTaskConfigs: PackTaskConfigs = {}
|
||||
for (const [key, config] of Object.entries(taskConfig)) {
|
||||
if (selectedTasks.has(key)) {
|
||||
selectedTaskConfigs[key as keyof PackTaskConfigs] = config
|
||||
}
|
||||
}
|
||||
|
||||
await createPack({
|
||||
name: packName.trim(),
|
||||
description: packDescription.trim(),
|
||||
author: packAuthor.trim(),
|
||||
tags: packTags,
|
||||
providers: selectedProviderConfigs,
|
||||
models: selectedModelConfigs,
|
||||
task_config: selectedTaskConfigs,
|
||||
})
|
||||
|
||||
toast({ title: '模板已提交审核,审核通过后将显示在市场中' })
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
toast({ title: error instanceof Error ? error.message : '提交失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setStep(1)
|
||||
setPackName('')
|
||||
setPackDescription('')
|
||||
setPackAuthor('')
|
||||
setPackTags([])
|
||||
setSelectedProviders(new Set())
|
||||
setSelectedModels(new Set())
|
||||
setSelectedTasks(new Set())
|
||||
}
|
||||
|
||||
const totalSteps = 2
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline">
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
分享配置
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
分享配置模板
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
步骤 {step} / {totalSteps}:
|
||||
{step === 1 && '选择要分享的配置'}
|
||||
{step === 2 && '填写模板信息'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(85vh-220px)] pr-4">
|
||||
{loading ? (
|
||||
<div className="py-8 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
|
||||
<p className="mt-4 text-muted-foreground">正在加载当前配置...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 步骤 1: 选择配置 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>安全提示</AlertTitle>
|
||||
<AlertDescription>
|
||||
分享的配置将<strong>不包含</strong> API Key,其他用户需要自行配置。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="providers" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="providers">
|
||||
<Server className="w-4 h-4 mr-2" />
|
||||
API 提供商
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedProviders.size}/{providers.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
<Layers className="w-4 h-4 mr-2" />
|
||||
模型配置
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedModels.size}/{models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks">
|
||||
<ListChecks className="w-4 h-4 mr-2" />
|
||||
任务配置
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedTasks.size}/{Object.keys(taskConfig).length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 提供商选择 */}
|
||||
<TabsContent value="providers" className="space-y-2 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={selectAllProviders}>
|
||||
{selectedProviders.size === providers.length ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
</div>
|
||||
{providers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
暂无提供商配置
|
||||
</p>
|
||||
) : (
|
||||
providers.map(provider => (
|
||||
<div
|
||||
key={provider.name}
|
||||
className="flex items-center space-x-2 p-2 rounded hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`provider-${provider.name}`}
|
||||
checked={selectedProviders.has(provider.name)}
|
||||
onCheckedChange={() => toggleProvider(provider.name)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`provider-${provider.name}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">{provider.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{provider.base_url}
|
||||
</span>
|
||||
</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.client_type}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<TabsContent value="models" className="space-y-2 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={selectAllModels}>
|
||||
{selectedModels.size === models.length ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
</div>
|
||||
{models.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
暂无模型配置
|
||||
</p>
|
||||
) : (
|
||||
models.map(model => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="flex items-center space-x-2 p-2 rounded hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`model-${model.name}`}
|
||||
checked={selectedModels.has(model.name)}
|
||||
onCheckedChange={() => toggleModel(model.name)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`model-${model.name}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{model.model_identifier}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.api_provider}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 任务配置选择 */}
|
||||
<TabsContent value="tasks" className="space-y-2 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={selectAllTasks}>
|
||||
{selectedTasks.size === Object.keys(taskConfig).length ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(taskConfig).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
暂无任务配置
|
||||
</p>
|
||||
) : (
|
||||
Object.entries(taskConfig).map(([key, config]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="space-y-2 p-2 rounded hover:bg-muted"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`task-${key}`}
|
||||
checked={selectedTasks.has(key)}
|
||||
onCheckedChange={() => toggleTask(key)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`task-${key}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{TASK_TYPE_NAMES[key] || key}
|
||||
</span>
|
||||
</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.model_list.length} 个模型
|
||||
</Badge>
|
||||
</div>
|
||||
{config.model_list && config.model_list.length > 0 && (
|
||||
<div className="ml-6 flex flex-wrap gap-1">
|
||||
{config.model_list.map((modelName: string) => {
|
||||
const model = models.find(m => m.name === modelName)
|
||||
const isSelected = selectedModels.has(modelName)
|
||||
return (
|
||||
<Badge
|
||||
key={modelName}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className="text-xs cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => toggleModel(modelName)}
|
||||
>
|
||||
{modelName}
|
||||
{model && (
|
||||
<span className="ml-1 opacity-70">
|
||||
({model.api_provider})
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤 2: 填写信息 */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* 选择摘要 */}
|
||||
<div className="flex gap-4 text-sm p-3 bg-muted rounded-lg">
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="w-4 h-4" />
|
||||
{selectedProviders.size} 个提供商
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Layers className="w-4 h-4" />
|
||||
{selectedModels.size} 个模型
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{selectedTasks.size} 个任务
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pack-name">模板名称 *</Label>
|
||||
<Input
|
||||
id="pack-name"
|
||||
placeholder="例如:高性价比国产模型配置"
|
||||
value={packName}
|
||||
onChange={e => setPackName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{packName.length}/50
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pack-description">模板描述 *</Label>
|
||||
<Textarea
|
||||
id="pack-description"
|
||||
placeholder="详细描述这个配置模板的特点、适用场景等..."
|
||||
value={packDescription}
|
||||
onChange={e => setPackDescription(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{packDescription.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pack-author">作者名称 *</Label>
|
||||
<Input
|
||||
id="pack-author"
|
||||
placeholder="你的昵称或 ID"
|
||||
value={packAuthor}
|
||||
onChange={e => setPackAuthor(e.target.value)}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>标签(可选,最多 5 个)</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_TAGS.map(tag => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={packTags.includes(tag) ? 'default' : 'outline'}
|
||||
className="cursor-pointer transition-colors"
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{packTags.includes(tag) && <Check className="w-3 h-3 mr-1" />}
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>审核说明</AlertTitle>
|
||||
<AlertDescription>
|
||||
提交后需要经过审核才能在市场中展示。审核通常在 1-3 个工作日内完成。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex justify-between pt-4 border-t">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={submitting}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
{step < totalSteps ? (
|
||||
<Button
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={
|
||||
loading ||
|
||||
(selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0)
|
||||
}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
提交审核
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
8
dashboard/src/components/survey/index.ts
Normal file
8
dashboard/src/components/survey/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 问卷组件导出
|
||||
*/
|
||||
|
||||
export { SurveyRenderer } from './survey-renderer'
|
||||
export { SurveyQuestion } from './survey-question'
|
||||
export { SurveyResults } from './survey-results'
|
||||
export type { SurveyRendererProps } from './survey-renderer'
|
||||
247
dashboard/src/components/survey/survey-question.tsx
Normal file
247
dashboard/src/components/survey/survey-question.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 单个问题渲染组件
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Star } from 'lucide-react'
|
||||
import type { SurveyQuestion as SurveyQuestionType } from '@/types/survey'
|
||||
|
||||
interface SurveyQuestionProps {
|
||||
question: SurveyQuestionType
|
||||
value: string | string[] | number | undefined
|
||||
onChange: (value: string | string[] | number) => void
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SurveyQuestion({
|
||||
question,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled = false
|
||||
}: SurveyQuestionProps) {
|
||||
const [hoverRating, setHoverRating] = useState<number | null>(null)
|
||||
|
||||
// 如果问题设置了只读,则禁用输入
|
||||
const isDisabled = disabled || question.readOnly
|
||||
|
||||
const renderQuestion = () => {
|
||||
switch (question.type) {
|
||||
case 'single':
|
||||
return (
|
||||
<RadioGroup
|
||||
value={value as string || ''}
|
||||
onValueChange={onChange}
|
||||
disabled={isDisabled}
|
||||
className="space-y-2"
|
||||
>
|
||||
{question.options?.map((option) => (
|
||||
<div key={option.id} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`${question.id}-${option.id}`} />
|
||||
<Label
|
||||
htmlFor={`${question.id}-${option.id}`}
|
||||
className="cursor-pointer font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
|
||||
case 'multiple': {
|
||||
const selectedValues = (value as string[]) || []
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{question.options?.map((option) => (
|
||||
<div key={option.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${question.id}-${option.id}`}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
disabled={isDisabled || (
|
||||
question.maxSelections !== undefined &&
|
||||
selectedValues.length >= question.maxSelections &&
|
||||
!selectedValues.includes(option.value)
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange([...selectedValues, option.value])
|
||||
} else {
|
||||
onChange(selectedValues.filter(v => v !== option.value))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${question.id}-${option.id}`}
|
||||
className="cursor-pointer font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
{question.maxSelections && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
最多选择 {question.maxSelections} 项
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<Input
|
||||
value={value as string || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={question.placeholder || '请输入...'}
|
||||
disabled={isDisabled}
|
||||
readOnly={question.readOnly}
|
||||
maxLength={question.maxLength}
|
||||
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Textarea
|
||||
value={value as string || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={question.placeholder || '请输入...'}
|
||||
disabled={isDisabled}
|
||||
readOnly={question.readOnly}
|
||||
maxLength={question.maxLength}
|
||||
rows={4}
|
||||
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
|
||||
/>
|
||||
{question.maxLength && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{(value as string || '').length} / {question.maxLength}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'rating': {
|
||||
const ratingValue = (value as number) || 0
|
||||
const displayRating = hoverRating !== null ? hoverRating : ratingValue
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"p-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring rounded",
|
||||
isDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => !isDisabled && setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(null)}
|
||||
onClick={() => !isDisabled && onChange(star)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-6 w-6 transition-colors",
|
||||
star <= displayRating
|
||||
? "fill-yellow-400 text-yellow-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{ratingValue > 0 && (
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{ratingValue} / 5
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'scale': {
|
||||
const min = question.min ?? 1
|
||||
const max = question.max ?? 10
|
||||
const step = question.step ?? 1
|
||||
const scaleValue = (value as number) ?? min
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Slider
|
||||
value={[scaleValue]}
|
||||
onValueChange={([val]) => onChange(val)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{question.minLabel || min}</span>
|
||||
<span className="font-medium text-foreground">{scaleValue}</span>
|
||||
<span>{question.maxLabel || max}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'dropdown':
|
||||
return (
|
||||
<Select
|
||||
value={value as string || ''}
|
||||
onValueChange={onChange}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={question.placeholder || '请选择...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{question.options?.map((option) => (
|
||||
<SelectItem key={option.id} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="text-muted-foreground">不支持的问题类型</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-medium">
|
||||
{question.title}
|
||||
{question.required && (
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{question.description && (
|
||||
<p className="text-sm text-muted-foreground">{question.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderQuestion()}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
407
dashboard/src/components/survey/survey-renderer.tsx
Normal file
407
dashboard/src/components/survey/survey-renderer.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 问卷渲染器组件
|
||||
* 读取 JSON 配置并展示问卷界面
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { SurveyQuestion } from './survey-question'
|
||||
import { submitSurvey, checkUserSubmission } from '@/lib/survey-api'
|
||||
import type { SurveyConfig, QuestionAnswer } from '@/types/survey'
|
||||
|
||||
export interface SurveyRendererProps {
|
||||
/** 问卷配置 */
|
||||
config: SurveyConfig
|
||||
/** 初始答案(用于预填充,如自动填写版本号) */
|
||||
initialAnswers?: QuestionAnswer[]
|
||||
/** 提交成功回调 */
|
||||
onSubmitSuccess?: (submissionId: string) => void
|
||||
/** 提交失败回调 */
|
||||
onSubmitError?: (error: string) => void
|
||||
/** 是否显示进度条 */
|
||||
showProgress?: boolean
|
||||
/** 是否分页显示(每页一题) */
|
||||
paginateQuestions?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
type AnswerMap = Record<string, string | string[] | number | undefined>
|
||||
|
||||
export function SurveyRenderer({
|
||||
config,
|
||||
initialAnswers,
|
||||
onSubmitSuccess,
|
||||
onSubmitError,
|
||||
showProgress = true,
|
||||
paginateQuestions = false,
|
||||
className
|
||||
}: SurveyRendererProps) {
|
||||
// 将 initialAnswers 转换为 AnswerMap
|
||||
const getInitialAnswerMap = useCallback((): AnswerMap => {
|
||||
if (!initialAnswers || initialAnswers.length === 0) return {}
|
||||
return initialAnswers.reduce((acc, answer) => {
|
||||
acc[answer.questionId] = answer.value
|
||||
return acc
|
||||
}, {} as AnswerMap)
|
||||
}, [initialAnswers])
|
||||
|
||||
const [answers, setAnswers] = useState<AnswerMap>(() => getInitialAnswerMap())
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submissionId, setSubmissionId] = useState<string | null>(null)
|
||||
const [hasAlreadySubmitted, setHasAlreadySubmitted] = useState(false)
|
||||
const [isCheckingSubmission, setIsCheckingSubmission] = useState(true)
|
||||
|
||||
// 当 initialAnswers 变化时更新答案(合并而非替换)
|
||||
useEffect(() => {
|
||||
if (initialAnswers && initialAnswers.length > 0) {
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
...getInitialAnswerMap()
|
||||
}))
|
||||
}
|
||||
}, [initialAnswers, getInitialAnswerMap])
|
||||
|
||||
// 检查是否已提交过
|
||||
useEffect(() => {
|
||||
const checkSubmission = async () => {
|
||||
if (!config.settings?.allowMultiple) {
|
||||
const result = await checkUserSubmission(config.id)
|
||||
if (result.success && result.hasSubmitted) {
|
||||
setHasAlreadySubmitted(true)
|
||||
}
|
||||
}
|
||||
setIsCheckingSubmission(false)
|
||||
}
|
||||
checkSubmission()
|
||||
}, [config.id, config.settings?.allowMultiple])
|
||||
|
||||
// 检查问卷是否在有效期内
|
||||
const isWithinTimeRange = useCallback(() => {
|
||||
const now = new Date()
|
||||
if (config.settings?.startTime && new Date(config.settings.startTime) > now) {
|
||||
return false
|
||||
}
|
||||
if (config.settings?.endTime && new Date(config.settings.endTime) < now) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [config.settings?.startTime, config.settings?.endTime])
|
||||
|
||||
// 计算进度
|
||||
const answeredCount = config.questions.filter(q => {
|
||||
const answer = answers[q.id]
|
||||
if (answer === undefined || answer === null) return false
|
||||
if (Array.isArray(answer)) return answer.length > 0
|
||||
if (typeof answer === 'string') return answer.trim() !== ''
|
||||
return true
|
||||
}).length
|
||||
|
||||
const progress = (answeredCount / config.questions.length) * 100
|
||||
|
||||
// 更新答案
|
||||
const handleAnswerChange = useCallback((questionId: string, value: string | string[] | number) => {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
// 清除该问题的错误
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[questionId]
|
||||
return newErrors
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 验证答案
|
||||
const validateAnswers = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
for (const question of config.questions) {
|
||||
if (question.required) {
|
||||
const answer = answers[question.id]
|
||||
|
||||
if (answer === undefined || answer === null) {
|
||||
newErrors[question.id] = '此题为必填项'
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(answer) && answer.length === 0) {
|
||||
newErrors[question.id] = '请至少选择一项'
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof answer === 'string' && answer.trim() === '') {
|
||||
newErrors[question.id] = '此题为必填项'
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 文本长度验证
|
||||
if (question.minLength && typeof answers[question.id] === 'string') {
|
||||
const text = answers[question.id] as string
|
||||
if (text.length < question.minLength) {
|
||||
newErrors[question.id] = `至少需要 ${question.minLength} 个字符`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [config.questions, answers])
|
||||
|
||||
// 提交问卷
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateAnswers()) {
|
||||
// 如果是分页模式,跳转到第一个有错误的问题
|
||||
if (paginateQuestions) {
|
||||
const firstErrorIndex = config.questions.findIndex(q => errors[q.id])
|
||||
if (firstErrorIndex >= 0) {
|
||||
setCurrentPage(firstErrorIndex)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setSubmitError(null)
|
||||
|
||||
try {
|
||||
// 构建答案列表
|
||||
const answerList: QuestionAnswer[] = config.questions
|
||||
.filter(q => answers[q.id] !== undefined)
|
||||
.map(q => ({
|
||||
questionId: q.id,
|
||||
value: answers[q.id]!
|
||||
}))
|
||||
|
||||
const result = await submitSurvey(
|
||||
config.id,
|
||||
config.version,
|
||||
answerList,
|
||||
{ allowMultiple: config.settings?.allowMultiple }
|
||||
)
|
||||
|
||||
if (result.success && result.submissionId) {
|
||||
setIsSubmitted(true)
|
||||
setSubmissionId(result.submissionId)
|
||||
onSubmitSuccess?.(result.submissionId)
|
||||
} else {
|
||||
const error = result.error || '提交失败'
|
||||
setSubmitError(error)
|
||||
onSubmitError?.(error)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : '提交失败'
|
||||
setSubmitError(errorMsg)
|
||||
onSubmitError?.(errorMsg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [validateAnswers, paginateQuestions, config, answers, errors, onSubmitSuccess, onSubmitError])
|
||||
|
||||
// 分页导航
|
||||
const goToPage = useCallback((page: number) => {
|
||||
if (page >= 0 && page < config.questions.length) {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}, [config.questions.length])
|
||||
|
||||
// 检查中
|
||||
if (isCheckingSubmission) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 已提交过
|
||||
if (hasAlreadySubmitted && !config.settings?.allowMultiple) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>{config.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
你已经提交过这份问卷了,感谢参与!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 不在有效期内
|
||||
if (!isWithinTimeRange()) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>{config.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
问卷不在有效期内
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 提交成功
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="h-6 w-6" />
|
||||
提交成功
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<p className="text-center text-muted-foreground">
|
||||
{config.settings?.thankYouMessage || '感谢你的参与!'}
|
||||
</p>
|
||||
{submissionId && (
|
||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||
提交编号:{submissionId}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 问卷展示
|
||||
const questionsToShow = paginateQuestions
|
||||
? [config.questions[currentPage]]
|
||||
: config.questions
|
||||
|
||||
return (
|
||||
<div className={cn("h-full flex flex-col", className)}>
|
||||
{/* 问卷头部 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 mb-4 shrink-0">
|
||||
<h2 className="text-xl font-semibold">{config.title}</h2>
|
||||
{config.description && (
|
||||
<p className="text-muted-foreground mt-1 text-sm">{config.description}</p>
|
||||
)}
|
||||
{showProgress && (
|
||||
<div className="space-y-1 pt-3">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>进度</span>
|
||||
<span>{answeredCount} / {config.questions.length}</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 问卷内容 - 可滚动区域 */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-4 pr-4">
|
||||
{questionsToShow.map((question, index) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={cn(
|
||||
"p-4 rounded-lg border bg-card",
|
||||
errors[question.id] ? "border-destructive bg-destructive/5" : "border-border"
|
||||
)}
|
||||
>
|
||||
{paginateQuestions && (
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
问题 {currentPage + 1} / {config.questions.length}
|
||||
</div>
|
||||
)}
|
||||
{!paginateQuestions && (
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
{index + 1}.
|
||||
</div>
|
||||
)}
|
||||
<SurveyQuestion
|
||||
question={question}
|
||||
value={answers[question.id]}
|
||||
onChange={(value) => handleAnswerChange(question.id, value)}
|
||||
error={errors[question.id]}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 提交按钮区域 */}
|
||||
<div className="flex justify-between items-center py-4">
|
||||
{paginateQuestions ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 0 || isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一题
|
||||
</Button>
|
||||
|
||||
{currentPage === config.questions.length - 1 ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
提交问卷
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
下一题
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<span className="text-destructive">
|
||||
还有 {Object.keys(errors).length} 个必填项未完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="lg"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
提交问卷
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
dashboard/src/components/survey/survey-results.tsx
Normal file
292
dashboard/src/components/survey/survey-results.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 问卷结果查看组件
|
||||
* 展示问卷统计数据和用户提交记录
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Users, FileText, Clock, Star, BarChart3 } from 'lucide-react'
|
||||
import { getSurveyStats, getUserSubmissions } from '@/lib/survey-api'
|
||||
import type { SurveyConfig, SurveyStats, StoredSubmission } from '@/types/survey'
|
||||
|
||||
interface SurveyResultsProps {
|
||||
/** 问卷配置 */
|
||||
config: SurveyConfig
|
||||
/** 是否显示用户提交记录 */
|
||||
showUserSubmissions?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SurveyResults({
|
||||
config,
|
||||
showUserSubmissions = true,
|
||||
className
|
||||
}: SurveyResultsProps) {
|
||||
const [stats, setStats] = useState<SurveyStats | null>(null)
|
||||
const [userSubmissions, setUserSubmissions] = useState<StoredSubmission[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 获取统计数据
|
||||
const statsResult = await getSurveyStats(config.id)
|
||||
if (statsResult.success && statsResult.stats) {
|
||||
setStats(statsResult.stats)
|
||||
}
|
||||
|
||||
// 获取用户提交记录
|
||||
if (showUserSubmissions) {
|
||||
const submissionsResult = await getUserSubmissions(config.id)
|
||||
if (submissionsResult.success && submissionsResult.submissions) {
|
||||
setUserSubmissions(submissionsResult.submissions)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载数据失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [config.id, showUserSubmissions])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
{config.title} - 统计结果
|
||||
</CardTitle>
|
||||
{config.description && (
|
||||
<CardDescription>{config.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* 概览统计 */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">总提交数</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats?.totalSubmissions || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="text-sm">独立用户</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats?.uniqueUsers || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm">最后提交</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{stats?.lastSubmissionAt
|
||||
? new Date(stats.lastSubmissionAt).toLocaleDateString()
|
||||
: '-'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="stats" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="stats">问题统计</TabsTrigger>
|
||||
{showUserSubmissions && (
|
||||
<TabsTrigger value="submissions">我的提交</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stats" className="mt-4">
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{config.questions.map((question, index) => {
|
||||
const qStats = stats?.questionStats[question.id]
|
||||
|
||||
return (
|
||||
<div key={question.id} className="p-4 rounded-lg border">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
问题 {index + 1}
|
||||
</div>
|
||||
<div className="font-medium mb-3">{question.title}</div>
|
||||
|
||||
{qStats ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
回答人数:{qStats.answered}
|
||||
</div>
|
||||
|
||||
{/* 选择题统计 */}
|
||||
{qStats.optionCounts && question.options && (
|
||||
<div className="space-y-2">
|
||||
{question.options.map(option => {
|
||||
const count = qStats.optionCounts?.[option.value] || 0
|
||||
const percentage = qStats.answered > 0
|
||||
? (count / qStats.answered) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div key={option.id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{count} ({percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 评分/量表统计 */}
|
||||
{qStats.average !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm">
|
||||
平均分:{qStats.average.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文本答案样本 */}
|
||||
{qStats.sampleAnswers && qStats.sampleAnswers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
部分回答:
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{qStats.sampleAnswers.map((answer, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-sm p-2 bg-muted/50 rounded text-muted-foreground"
|
||||
>
|
||||
"{answer}"
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{showUserSubmissions && (
|
||||
<TabsContent value="submissions" className="mt-4">
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{userSubmissions.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
你还没有提交过这份问卷
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 pr-4">
|
||||
{userSubmissions.map((submission) => (
|
||||
<div key={submission.id} className="p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="outline">
|
||||
{new Date(submission.submittedAt).toLocaleString()}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ID: {submission.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{submission.answers.map((answer) => {
|
||||
const question = config.questions.find(
|
||||
q => q.id === answer.questionId
|
||||
)
|
||||
|
||||
if (!question) return null
|
||||
|
||||
// 格式化答案显示
|
||||
let displayValue: string
|
||||
if (Array.isArray(answer.value)) {
|
||||
const labels = answer.value.map(v => {
|
||||
const opt = question.options?.find(o => o.value === v)
|
||||
return opt?.label || v
|
||||
})
|
||||
displayValue = labels.join('、')
|
||||
} else if (typeof answer.value === 'number') {
|
||||
displayValue = answer.value.toString()
|
||||
} else {
|
||||
const opt = question.options?.find(
|
||||
o => o.value === answer.value
|
||||
)
|
||||
displayValue = opt?.label || answer.value
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={answer.questionId} className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{question.title}:
|
||||
</span>
|
||||
<span>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
139
dashboard/src/components/theme-provider.tsx
Normal file
139
dashboard/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ThemeProviderContext } from '@/lib/theme-context'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'ui-theme',
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
// 应用保存的主题色
|
||||
useEffect(() => {
|
||||
const savedAccentColor = localStorage.getItem('accent-color')
|
||||
if (savedAccentColor) {
|
||||
const root = document.documentElement
|
||||
const colors = {
|
||||
blue: {
|
||||
hsl: '221.2 83.2% 53.3%',
|
||||
darkHsl: '217.2 91.2% 59.8%',
|
||||
gradient: null
|
||||
},
|
||||
purple: {
|
||||
hsl: '271 91% 65%',
|
||||
darkHsl: '270 95% 75%',
|
||||
gradient: null
|
||||
},
|
||||
green: {
|
||||
hsl: '142 71% 45%',
|
||||
darkHsl: '142 76% 36%',
|
||||
gradient: null
|
||||
},
|
||||
orange: {
|
||||
hsl: '25 95% 53%',
|
||||
darkHsl: '20 90% 48%',
|
||||
gradient: null
|
||||
},
|
||||
pink: {
|
||||
hsl: '330 81% 60%',
|
||||
darkHsl: '330 85% 70%',
|
||||
gradient: null
|
||||
},
|
||||
red: {
|
||||
hsl: '0 84% 60%',
|
||||
darkHsl: '0 90% 70%',
|
||||
gradient: null
|
||||
},
|
||||
|
||||
// 渐变色
|
||||
'gradient-sunset': {
|
||||
hsl: '15 95% 60%',
|
||||
darkHsl: '15 95% 65%',
|
||||
gradient: 'linear-gradient(135deg, hsl(25 95% 53%) 0%, hsl(330 81% 60%) 100%)'
|
||||
},
|
||||
'gradient-ocean': {
|
||||
hsl: '200 90% 55%',
|
||||
darkHsl: '200 90% 60%',
|
||||
gradient: 'linear-gradient(135deg, hsl(221.2 83.2% 53.3%) 0%, hsl(189 94% 43%) 100%)'
|
||||
},
|
||||
'gradient-forest': {
|
||||
hsl: '150 70% 45%',
|
||||
darkHsl: '150 75% 40%',
|
||||
gradient: 'linear-gradient(135deg, hsl(142 71% 45%) 0%, hsl(158 64% 52%) 100%)'
|
||||
},
|
||||
'gradient-aurora': {
|
||||
hsl: '310 85% 65%',
|
||||
darkHsl: '310 90% 70%',
|
||||
gradient: 'linear-gradient(135deg, hsl(271 91% 65%) 0%, hsl(330 81% 60%) 100%)'
|
||||
},
|
||||
'gradient-fire': {
|
||||
hsl: '15 95% 55%',
|
||||
darkHsl: '15 95% 60%',
|
||||
gradient: 'linear-gradient(135deg, hsl(0 84% 60%) 0%, hsl(25 95% 53%) 100%)'
|
||||
},
|
||||
'gradient-twilight': {
|
||||
hsl: '250 90% 60%',
|
||||
darkHsl: '250 95% 65%',
|
||||
gradient: 'linear-gradient(135deg, hsl(239 84% 67%) 0%, hsl(271 91% 65%) 100%)'
|
||||
},
|
||||
}
|
||||
|
||||
const selectedColor = colors[savedAccentColor as keyof typeof colors]
|
||||
if (selectedColor) {
|
||||
root.style.setProperty('--primary', selectedColor.hsl)
|
||||
|
||||
// 设置渐变(如果有)
|
||||
if (selectedColor.gradient) {
|
||||
root.style.setProperty('--primary-gradient', selectedColor.gradient)
|
||||
root.classList.add('has-gradient')
|
||||
} else {
|
||||
root.style.removeProperty('--primary-gradient')
|
||||
root.classList.remove('has-gradient')
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
5
dashboard/src/components/tour/index.ts
Normal file
5
dashboard/src/components/tour/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { TourProvider } from './tour-provider'
|
||||
export { TourRenderer } from './tour-renderer'
|
||||
export { useTour } from './use-tour'
|
||||
export { TourContext } from './tour-context'
|
||||
export type { TourId, TourState, TourContextType } from './types'
|
||||
4
dashboard/src/components/tour/tour-context.ts
Normal file
4
dashboard/src/components/tour/tour-context.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react'
|
||||
import type { TourContextType } from './types'
|
||||
|
||||
export const TourContext = createContext<TourContextType | null>(null)
|
||||
177
dashboard/src/components/tour/tour-provider.tsx
Normal file
177
dashboard/src/components/tour/tour-provider.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useCallback, type ReactNode } from 'react'
|
||||
import type { Step, CallBackProps, Status } from 'react-joyride'
|
||||
import { TourContext } from './tour-context'
|
||||
import type { TourId, TourState } from './types'
|
||||
|
||||
const COMPLETED_TOURS_KEY = 'maibot-completed-tours'
|
||||
|
||||
// 从 localStorage 读取已完成的 Tours
|
||||
function getCompletedTours(): Set<TourId> {
|
||||
try {
|
||||
const stored = localStorage.getItem(COMPLETED_TOURS_KEY)
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set()
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
// 保存已完成的 Tours 到 localStorage
|
||||
function saveCompletedTours(tours: Set<TourId>) {
|
||||
localStorage.setItem(COMPLETED_TOURS_KEY, JSON.stringify([...tours]))
|
||||
}
|
||||
|
||||
export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<TourState>({
|
||||
activeTourId: null,
|
||||
stepIndex: 0,
|
||||
isRunning: false,
|
||||
})
|
||||
|
||||
// 使用 useState 存储 tours(Map 对象是可变的,可以直接修改)
|
||||
const [tours] = useState<Map<TourId, Step[]>>(() => new Map())
|
||||
const [completedTours, setCompletedTours] = useState<Set<TourId>>(getCompletedTours)
|
||||
// 用于强制重新渲染的计数器
|
||||
const [, forceUpdate] = useState(0)
|
||||
|
||||
const registerTour = useCallback((tourId: TourId, steps: Step[]) => {
|
||||
tours.set(tourId, steps)
|
||||
// 强制更新以确保 context 消费者能获取到最新数据
|
||||
forceUpdate(n => n + 1)
|
||||
}, [tours])
|
||||
|
||||
const unregisterTour = useCallback((tourId: TourId) => {
|
||||
tours.delete(tourId)
|
||||
// 如果正在运行的 Tour 被注销,停止它
|
||||
setState(prev => {
|
||||
if (prev.activeTourId === tourId) {
|
||||
return { ...prev, activeTourId: null, isRunning: false, stepIndex: 0 }
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [tours])
|
||||
|
||||
const startTour = useCallback((tourId: TourId, startIndex = 0) => {
|
||||
if (tours.has(tourId)) {
|
||||
setState({
|
||||
activeTourId: tourId,
|
||||
stepIndex: startIndex,
|
||||
isRunning: true,
|
||||
})
|
||||
}
|
||||
}, [tours])
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const goToStep = useCallback((index: number) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stepIndex: index,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stepIndex: prev.stepIndex + 1,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stepIndex: Math.max(0, prev.stepIndex - 1),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const getCurrentSteps = useCallback((): Step[] => {
|
||||
if (!state.activeTourId) return []
|
||||
return tours.get(state.activeTourId) || []
|
||||
}, [state.activeTourId, tours])
|
||||
|
||||
const markTourCompleted = useCallback((tourId: TourId) => {
|
||||
setCompletedTours(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(tourId)
|
||||
saveCompletedTours(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleJoyrideCallback = useCallback((data: CallBackProps) => {
|
||||
const { action, index, status, type } = data
|
||||
const finishedStatuses: Status[] = ['finished', 'skipped']
|
||||
|
||||
// 处理关闭按钮点击
|
||||
if (action === 'close') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
stepIndex: 0,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedStatuses.includes(status)) {
|
||||
// Tour 完成或跳过
|
||||
setState(prev => {
|
||||
if (status === 'finished' && prev.activeTourId) {
|
||||
// 使用 setTimeout 避免在 setState 中调用另一个 setState
|
||||
setTimeout(() => markTourCompleted(prev.activeTourId!), 0)
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
isRunning: false,
|
||||
stepIndex: 0,
|
||||
}
|
||||
})
|
||||
} else if (type === 'step:after') {
|
||||
// 步骤切换后更新索引
|
||||
if (action === 'next') {
|
||||
setState(prev => ({ ...prev, stepIndex: index + 1 }))
|
||||
} else if (action === 'prev') {
|
||||
setState(prev => ({ ...prev, stepIndex: index - 1 }))
|
||||
}
|
||||
}
|
||||
}, [markTourCompleted])
|
||||
|
||||
const isTourCompleted = useCallback((tourId: TourId): boolean => {
|
||||
return completedTours.has(tourId)
|
||||
}, [completedTours])
|
||||
|
||||
const resetTourCompleted = useCallback((tourId: TourId) => {
|
||||
setCompletedTours(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(tourId)
|
||||
saveCompletedTours(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TourContext.Provider
|
||||
value={{
|
||||
state,
|
||||
tours,
|
||||
registerTour,
|
||||
unregisterTour,
|
||||
startTour,
|
||||
stopTour,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
getCurrentSteps,
|
||||
handleJoyrideCallback,
|
||||
isTourCompleted,
|
||||
markTourCompleted,
|
||||
resetTourCompleted,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TourContext.Provider>
|
||||
)
|
||||
}
|
||||
217
dashboard/src/components/tour/tour-renderer.tsx
Normal file
217
dashboard/src/components/tour/tour-renderer.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import Joyride from 'react-joyride'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTour } from './use-tour'
|
||||
|
||||
// Joyride 主题配置
|
||||
const joyrideStyles = {
|
||||
options: {
|
||||
zIndex: 10000,
|
||||
primaryColor: 'hsl(var(--primary))',
|
||||
textColor: 'hsl(var(--foreground))',
|
||||
backgroundColor: 'hsl(var(--background))',
|
||||
arrowColor: 'hsl(var(--background))',
|
||||
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
tooltip: {
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: '1rem',
|
||||
},
|
||||
tooltipContainer: {
|
||||
textAlign: 'left' as const,
|
||||
},
|
||||
tooltipTitle: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
marginBottom: '0.5rem',
|
||||
},
|
||||
tooltipContent: {
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.5rem 0',
|
||||
},
|
||||
buttonNext: {
|
||||
backgroundColor: 'hsl(var(--primary))',
|
||||
color: 'hsl(var(--primary-foreground))',
|
||||
borderRadius: 'calc(var(--radius) - 2px)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.5rem 1rem',
|
||||
},
|
||||
buttonBack: {
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
fontSize: '0.875rem',
|
||||
marginRight: '0.5rem',
|
||||
},
|
||||
buttonSkip: {
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
buttonClose: {
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
spotlight: {
|
||||
borderRadius: 'var(--radius)',
|
||||
},
|
||||
}
|
||||
|
||||
// 中文本地化
|
||||
const locale = {
|
||||
back: '上一步',
|
||||
close: '关闭',
|
||||
last: '完成',
|
||||
next: '下一步',
|
||||
nextLabelWithProgress: '下一步 ({step}/{steps})',
|
||||
open: '打开对话框',
|
||||
skip: '跳过',
|
||||
}
|
||||
|
||||
export function TourRenderer() {
|
||||
const { state, getCurrentSteps, handleJoyrideCallback } = useTour()
|
||||
const steps = getCurrentSteps()
|
||||
const [targetReady, setTargetReady] = useState(false)
|
||||
const prevStepIndexRef = useRef(state.stepIndex)
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// 当步骤变化时,重置 targetReady 以强制重新检测和定位
|
||||
useEffect(() => {
|
||||
if (prevStepIndexRef.current !== state.stepIndex) {
|
||||
setTargetReady(false)
|
||||
prevStepIndexRef.current = state.stepIndex
|
||||
}
|
||||
}, [state.stepIndex])
|
||||
|
||||
// 等待当前步骤的目标元素出现
|
||||
useEffect(() => {
|
||||
if (!state.isRunning || steps.length === 0) {
|
||||
setTargetReady(false)
|
||||
return
|
||||
}
|
||||
|
||||
const currentStep = steps[state.stepIndex]
|
||||
if (!currentStep) {
|
||||
setTargetReady(false)
|
||||
return
|
||||
}
|
||||
|
||||
const target = currentStep.target
|
||||
if (target === 'body') {
|
||||
setTargetReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
setTargetReady(false)
|
||||
|
||||
// 每次步骤变化时,先等待一段时间让 DOM 更新(弹窗关闭动画等)
|
||||
const initialDelay = setTimeout(() => {
|
||||
const checkTarget = () => {
|
||||
const element = document.querySelector(target as string)
|
||||
if (element) {
|
||||
// 确保元素可见
|
||||
const rect = element.getBoundingClientRect()
|
||||
const isVisible = rect.width > 0 && rect.height > 0
|
||||
if (isVisible) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (checkTarget()) {
|
||||
// 找到元素后再等一小段时间,确保动画完成
|
||||
setTimeout(() => setTargetReady(true), 100)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用轮询检测元素
|
||||
const intervalId = setInterval(() => {
|
||||
if (checkTarget()) {
|
||||
clearInterval(intervalId)
|
||||
// 找到元素后再等一小段时间
|
||||
setTimeout(() => setTargetReady(true), 100)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(intervalId)
|
||||
// 超时后设置 targetReady 为 true,让 Joyride 显示错误提示
|
||||
setTargetReady(true)
|
||||
}, 5000)
|
||||
|
||||
// 保存清理函数
|
||||
const cleanup = () => {
|
||||
clearInterval(intervalId)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
// 将清理函数保存到 ref 中以便外部清理
|
||||
cleanupRef.current = cleanup
|
||||
}, 150) // 等待 150ms 让 DOM 更新和动画完成
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialDelay)
|
||||
if (cleanupRef.current) {
|
||||
cleanupRef.current()
|
||||
cleanupRef.current = null
|
||||
}
|
||||
}
|
||||
}, [state.isRunning, state.stepIndex, steps])
|
||||
|
||||
// 创建一个高层级的容器用于渲染 Joyride
|
||||
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 创建或获取 tour 专用容器
|
||||
let container = document.getElementById('tour-portal-container') as HTMLDivElement | null
|
||||
if (!container) {
|
||||
container = document.createElement('div')
|
||||
container.id = 'tour-portal-container'
|
||||
container.style.cssText = 'position: fixed; top: 0; left: 0; z-index: 99999; pointer-events: none;'
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
|
||||
setPortalElement(container)
|
||||
|
||||
return () => {
|
||||
// 组件卸载时不删除容器,因为可能还会再用
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!state.isRunning || steps.length === 0 || !targetReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
const joyrideElement = (
|
||||
<Joyride
|
||||
key={`tour-step-${state.stepIndex}`}
|
||||
steps={steps}
|
||||
stepIndex={state.stepIndex}
|
||||
run={state.isRunning}
|
||||
continuous
|
||||
showSkipButton
|
||||
showProgress
|
||||
disableOverlayClose
|
||||
disableScrolling={false}
|
||||
disableScrollParentFix={false}
|
||||
callback={handleJoyrideCallback}
|
||||
styles={joyrideStyles}
|
||||
locale={locale}
|
||||
scrollOffset={80}
|
||||
scrollToFirstStep
|
||||
floaterProps={{
|
||||
styles: {
|
||||
floater: {
|
||||
zIndex: 99999,
|
||||
},
|
||||
},
|
||||
disableAnimation: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
// 使用 Portal 渲染到高层容器
|
||||
if (portalElement) {
|
||||
return createPortal(joyrideElement, portalElement)
|
||||
}
|
||||
|
||||
return joyrideElement
|
||||
}
|
||||
244
dashboard/src/components/tour/tours/model-assignment-tour.ts
Normal file
244
dashboard/src/components/tour/tours/model-assignment-tour.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Step, Placement } from 'react-joyride'
|
||||
|
||||
export const MODEL_ASSIGNMENT_TOUR_ID = 'model-assignment-tour'
|
||||
|
||||
// Tour 步骤定义
|
||||
export const modelAssignmentTourSteps: Step[] = [
|
||||
// Step 1: 全屏介绍
|
||||
{
|
||||
target: 'body',
|
||||
content: '本引导旨在帮助你配置模型提供商和对应的模型,并为麦麦的各个组件分配合适的模型。',
|
||||
placement: 'center' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 2: 侧边栏 - 模型提供商按钮(点击下一步会自动导航)
|
||||
{
|
||||
target: '[data-tour="sidebar-model-provider"]',
|
||||
content: '第一步,你需要配置模型提供商。模型提供商决定了你要使用谁家的模型,无论是单一厂商(如 DeepSeek),还是模型平台(如 Siliconflow),都可以在这里进行配置。点击"下一步"进入配置页面。',
|
||||
placement: 'right' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 3: 添加提供商按钮
|
||||
{
|
||||
target: '[data-tour="add-provider-button"]',
|
||||
content: '点击"添加提供商"按钮,开始配置你的模型提供商。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 4: 添加提供商弹窗
|
||||
{
|
||||
target: '[data-tour="provider-dialog"]',
|
||||
content: '在这里,你可以选择你想要配置的模型提供商,填写相关信息后保存即可。',
|
||||
placement: 'left' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 5: 名称输入框
|
||||
{
|
||||
target: '[data-tour="provider-name-input"]',
|
||||
content: '这里的名称是你为这个模型提供商起的一个名字,方便你在后续使用时识别它。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 6: API 密钥输入框
|
||||
{
|
||||
target: '[data-tour="provider-apikey-input"]',
|
||||
content: '这里需要填写你从模型提供商那里获取的 API 密钥,用于验证和调用模型服务。对于不同的提供商,获取 API 密钥的方式可能有所不同,请参考对应提供商的文档。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 7: URL 输入框
|
||||
{
|
||||
target: '[data-tour="provider-url-input"]',
|
||||
content: '这里需要填写模型提供商的 API 访问地址,确保填写正确以便系统能够连接到模型服务。对于不同的提供商,API 地址可能有所不同,请参考对应提供商的文档。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 8: 模板选择下拉框
|
||||
{
|
||||
target: '[data-tour="provider-template-select"]',
|
||||
content: '当然,如果你不知道如何填写这些信息,很多模型提供商在这里都提供了预设的模板供你选择,选择对应的模板后,相关信息会自动填充。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 9: 保存按钮
|
||||
{
|
||||
target: '[data-tour="provider-save-button"]',
|
||||
content: '填写完所有信息后,点击保存按钮,模型提供商就配置完成了。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 10: 取消按钮
|
||||
{
|
||||
target: '[data-tour="provider-cancel-button"]',
|
||||
content: '因为这次咱们什么都没有填写,所以点击取消按钮退出吧。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 11: 侧边栏 - 模型管理与分配按钮(点击下一步会自动导航)
|
||||
{
|
||||
target: '[data-tour="sidebar-model-management"]',
|
||||
content: '配置好模型提供商后,接下来我们需要为麦麦添加模型并分配功能。点击"下一步"进入模型管理页面。',
|
||||
placement: 'right' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 12: 添加模型按钮
|
||||
{
|
||||
target: '[data-tour="add-model-button"]',
|
||||
content: '在为麦麦的组件分配模型之前,首先需要添加你想要分配的模型,点击"添加模型"按钮开始添加。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 13: 添加模型弹窗
|
||||
{
|
||||
target: '[data-tour="model-dialog"]',
|
||||
content: '在这里,你可以选择你之前配置好的模型提供商,然后选择对应的模型来添加。',
|
||||
placement: 'left' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 14: 模型名称输入框
|
||||
{
|
||||
target: '[data-tour="model-name-input"]',
|
||||
content: '这里的模型名称是你为这个模型起的一个名字,方便你在后续使用时识别它。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 15: API 提供商下拉框
|
||||
{
|
||||
target: '[data-tour="model-provider-select"]',
|
||||
content: '在这里选择你之前配置好的模型提供商,这样系统才能知道你要添加哪个提供商的模型。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 16: 模型标识符输入框
|
||||
{
|
||||
target: '[data-tour="model-identifier-input"]',
|
||||
content: '这里需要填写你想要添加的模型的标识符,不同的模型提供商可能有不同的标识符格式,请参考对应提供商的文档。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 17: 保存按钮
|
||||
{
|
||||
target: '[data-tour="model-save-button"]',
|
||||
content: '填写完所有信息后,点击保存按钮,模型就添加完成了。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
// Step 18: 取消按钮
|
||||
{
|
||||
target: '[data-tour="model-cancel-button"]',
|
||||
content: '当然,因为这次咱们什么都没有填写,所以直接点击取消按钮退出吧,等你准备好了再来添加模型。',
|
||||
placement: 'top' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 19: 为模型分配功能标签页
|
||||
{
|
||||
target: '[data-tour="tasks-tab-trigger"]',
|
||||
content: '最后一步,添加好模型后,切换到"为模型分配功能"标签页,为麦麦的各个组件分配合适的模型。',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
// Step 20: 组件模型卡片的模型选择
|
||||
{
|
||||
target: '[data-tour="task-model-select"]',
|
||||
content: '在这里,你可以为每个组件选择一个或多个合适的模型,选择完成后配置会自动保存。恭喜你完成了模型配置的学习!',
|
||||
placement: 'bottom' as Placement,
|
||||
disableBeacon: true,
|
||||
disableOverlayClose: true,
|
||||
hideCloseButton: false,
|
||||
spotlightClicks: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 需要用户点击才能继续的步骤索引(0-based)
|
||||
// Step 2 (index 2): 点击添加提供商按钮
|
||||
// Step 9 (index 9): 点击取消按钮关闭提供商弹窗
|
||||
// Step 11 (index 11): 点击添加模型按钮
|
||||
// Step 17 (index 17): 点击取消按钮关闭模型弹窗
|
||||
// Step 18 (index 18): 点击标签页切换
|
||||
export const CLICK_TO_CONTINUE_STEPS = new Set([2, 9, 11, 17, 18])
|
||||
|
||||
// 步骤与路由的映射
|
||||
export const STEP_ROUTE_MAP: Record<number, string> = {
|
||||
0: '/config/model', // 起始页面
|
||||
1: '/config/model', // 侧边栏可见
|
||||
2: '/config/modelProvider', // 需要在模型提供商页面
|
||||
3: '/config/modelProvider',
|
||||
4: '/config/modelProvider',
|
||||
5: '/config/modelProvider',
|
||||
6: '/config/modelProvider',
|
||||
7: '/config/modelProvider',
|
||||
8: '/config/modelProvider',
|
||||
9: '/config/modelProvider',
|
||||
10: '/config/modelProvider',
|
||||
11: '/config/model', // 需要在模型管理页面
|
||||
12: '/config/model',
|
||||
13: '/config/model',
|
||||
14: '/config/model',
|
||||
15: '/config/model',
|
||||
16: '/config/model',
|
||||
17: '/config/model',
|
||||
18: '/config/model',
|
||||
19: '/config/model',
|
||||
}
|
||||
49
dashboard/src/components/tour/types.ts
Normal file
49
dashboard/src/components/tour/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Step, CallBackProps } from 'react-joyride'
|
||||
|
||||
// Tour ID 类型,用于区分不同的引导流程
|
||||
export type TourId = string
|
||||
|
||||
export interface TourState {
|
||||
// 当前激活的 Tour ID
|
||||
activeTourId: TourId | null
|
||||
// 当前步骤索引
|
||||
stepIndex: number
|
||||
// Tour 是否正在运行
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
export interface TourContextType {
|
||||
// 状态
|
||||
state: TourState
|
||||
// 注册的所有 Tour 步骤
|
||||
tours: Map<TourId, Step[]>
|
||||
|
||||
// 注册一个 Tour
|
||||
registerTour: (tourId: TourId, steps: Step[]) => void
|
||||
// 注销一个 Tour
|
||||
unregisterTour: (tourId: TourId) => void
|
||||
|
||||
// 开始一个 Tour
|
||||
startTour: (tourId: TourId, startIndex?: number) => void
|
||||
// 停止当前 Tour
|
||||
stopTour: () => void
|
||||
// 跳转到指定步骤
|
||||
goToStep: (index: number) => void
|
||||
// 下一步
|
||||
nextStep: () => void
|
||||
// 上一步
|
||||
prevStep: () => void
|
||||
|
||||
// 获取当前 Tour 的步骤
|
||||
getCurrentSteps: () => Step[]
|
||||
|
||||
// Joyride 回调处理
|
||||
handleJoyrideCallback: (data: CallBackProps) => void
|
||||
|
||||
// 检查用户是否已完成某个 Tour
|
||||
isTourCompleted: (tourId: TourId) => boolean
|
||||
// 标记 Tour 已完成
|
||||
markTourCompleted: (tourId: TourId) => void
|
||||
// 重置 Tour 完成状态
|
||||
resetTourCompleted: (tourId: TourId) => void
|
||||
}
|
||||
10
dashboard/src/components/tour/use-tour.ts
Normal file
10
dashboard/src/components/tour/use-tour.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react'
|
||||
import { TourContext } from './tour-context'
|
||||
|
||||
export function useTour() {
|
||||
const context = useContext(TourContext)
|
||||
if (!context) {
|
||||
throw new Error('useTour must be used within a TourProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
58
dashboard/src/components/ui/accordion.tsx
Normal file
58
dashboard/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
141
dashboard/src/components/ui/alert-dialog.tsx
Normal file
141
dashboard/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
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}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.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%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> & {
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
}
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
59
dashboard/src/components/ui/alert.tsx
Normal file
59
dashboard/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
48
dashboard/src/components/ui/avatar.tsx
Normal file
48
dashboard/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
37
dashboard/src/components/ui/badge.tsx
Normal file
37
dashboard/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"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: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Badge, badgeVariants }
|
||||
58
dashboard/src/components/ui/button.tsx
Normal file
58
dashboard/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
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"
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
211
dashboard/src/components/ui/calendar.tsx
Normal file
211
dashboard/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-[--cell-size] select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"bg-accent rounded-l-md",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
76
dashboard/src/components/ui/card.tsx
Normal file
76
dashboard/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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 bg-card text-card-foreground shadow",
|
||||
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("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 }
|
||||
378
dashboard/src/components/ui/chart.tsx
Normal file
378
dashboard/src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
type ChartTooltipContentProps = React.ComponentProps<"div"> & {
|
||||
active?: boolean
|
||||
payload?: any[]
|
||||
label?: string
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
labelFormatter?: (label: any, payload: any[]) => React.ReactNode
|
||||
formatter?: (value: any, name: string, item: any, index: number, payload?: any) => React.ReactNode
|
||||
color?: string
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChartTooltipContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item: any) => item.type !== "none")
|
||||
.map((item: any, index: number) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
type ChartLegendContentProps = React.ComponentProps<"div"> & {
|
||||
payload?: any[]
|
||||
verticalAlign?: "top" | "bottom"
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChartLegendContentProps
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item: any) => item.type !== "none")
|
||||
.map((item: any) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
27
dashboard/src/components/ui/checkbox.tsx
Normal file
27
dashboard/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("grid place-content-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
11
dashboard/src/components/ui/collapsible.tsx
Normal file
11
dashboard/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
152
dashboard/src/components/ui/command.tsx
Normal file
152
dashboard/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
197
dashboard/src/components/ui/context-menu.tsx
Normal file
197
dashboard/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[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 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[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 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
131
dashboard/src/components/ui/dialog.tsx
Normal file
131
dashboard/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<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
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
/** 阻止点击外部关闭(用于 Tour 运行时) */
|
||||
preventOutsideClose?: boolean
|
||||
/** 隐藏默认关闭按钮(当使用自定义关闭按钮时) */
|
||||
hideCloseButton?: boolean
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, ...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%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
onPointerDownOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
|
||||
onInteractOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideCloseButton && (
|
||||
<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.ElementRef<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.ElementRef<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,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
200
dashboard/src/components/ui/dropdown-menu.tsx
Normal file
200
dashboard/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[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}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[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}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
76
dashboard/src/components/ui/extra-params-dialog.tsx
Normal file
76
dashboard/src/components/ui/extra-params-dialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { KeyValueEditor } from "@/components/ui/key-value-editor"
|
||||
|
||||
interface ExtraParamsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export function ExtraParamsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
onChange,
|
||||
}: ExtraParamsDialogProps) {
|
||||
const [editingValue, setEditingValue] = useState<Record<string, unknown>>(value)
|
||||
|
||||
// 当对话框打开状态改变时的处理
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen) {
|
||||
// 打开时同步最新的 value
|
||||
setEditingValue(value)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(editingValue)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingValue(value) // 恢复原始值
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl h-[70vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑额外参数</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置模型调用时的额外参数,支持嵌套对象和数组
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<KeyValueEditor
|
||||
value={editingValue}
|
||||
onChange={setEditingValue}
|
||||
placeholder="添加额外参数(如 thinking、top_p 等)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
63
dashboard/src/components/ui/help-tooltip.tsx
Normal file
63
dashboard/src/components/ui/help-tooltip.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from "react"
|
||||
import { HelpCircle } from "lucide-react"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface HelpTooltipProps {
|
||||
content: React.ReactNode
|
||||
className?: string
|
||||
iconClassName?: string
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
align?: "start" | "center" | "end"
|
||||
maxWidth?: string
|
||||
}
|
||||
|
||||
export function HelpTooltip({
|
||||
content,
|
||||
className,
|
||||
iconClassName,
|
||||
side = "top",
|
||||
align = "center",
|
||||
maxWidth = "300px",
|
||||
}: HelpTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"transition-colors cursor-help",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<HelpCircle className={cn("h-4 w-4", iconClassName)} />
|
||||
<span className="sr-only">帮助信息</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
align={align}
|
||||
className={cn(
|
||||
"max-w-[var(--max-width)] text-sm leading-relaxed",
|
||||
"bg-background text-foreground",
|
||||
"border-2 border-primary shadow-lg",
|
||||
"p-4"
|
||||
)}
|
||||
style={{ "--max-width": maxWidth } as React.CSSProperties}
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
22
dashboard/src/components/ui/input.tsx
Normal file
22
dashboard/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
43
dashboard/src/components/ui/kbd.tsx
Normal file
43
dashboard/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const kbdVariants = cva(
|
||||
"pointer-events-none inline-flex select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono font-medium opacity-100",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-5 text-[10px]",
|
||||
default: "h-6 text-xs",
|
||||
lg: "h-7 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface KbdProps
|
||||
extends React.HTMLAttributes<HTMLElement>,
|
||||
VariantProps<typeof kbdVariants> {
|
||||
abbrTitle?: string
|
||||
}
|
||||
|
||||
const Kbd = React.forwardRef<HTMLElement, KbdProps>(
|
||||
({ className, size, abbrTitle, children, ...props }, ref) => {
|
||||
return (
|
||||
<kbd
|
||||
className={cn(kbdVariants({ size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{abbrTitle ? <abbr title={abbrTitle}>{children}</abbr> : children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
)
|
||||
Kbd.displayName = "Kbd"
|
||||
|
||||
export { Kbd }
|
||||
180
dashboard/src/components/ui/key-value-editor.tsx
Normal file
180
dashboard/src/components/ui/key-value-editor.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { AlertCircle, Check } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NestedKeyValueEditor } from "./nested-key-value-editor"
|
||||
|
||||
interface KeyValueEditorProps {
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// 验证 JSON 字符串
|
||||
function validateJson(jsonStr: string): { valid: boolean; error?: string; parsed?: Record<string, unknown> } {
|
||||
if (!jsonStr.trim()) {
|
||||
return { valid: true, parsed: {} }
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return { valid: false, error: '必须是一个 JSON 对象 {}' }
|
||||
}
|
||||
// 支持任意 JSON 值类型(包括嵌套对象和数组)
|
||||
return { valid: true, parsed: parsed as Record<string, unknown> }
|
||||
} catch {
|
||||
return { valid: false, error: 'JSON 格式错误' }
|
||||
}
|
||||
}
|
||||
|
||||
export function KeyValueEditor({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
placeholder = "添加额外参数...",
|
||||
}: KeyValueEditorProps) {
|
||||
const [mode, setMode] = useState<'list' | 'json'>('list')
|
||||
|
||||
const initialJsonText = useMemo(() =>
|
||||
Object.keys(value || {}).length > 0 ? JSON.stringify(value, null, 2) : '',
|
||||
[value]
|
||||
)
|
||||
|
||||
const [editingJsonText, setEditingJsonText] = useState(initialJsonText)
|
||||
const [jsonError, setJsonError] = useState<string | null>(null)
|
||||
|
||||
// 当 value 变化时重置编辑状态
|
||||
useEffect(() => {
|
||||
setEditingJsonText(initialJsonText)
|
||||
}, [initialJsonText])
|
||||
|
||||
// JSON 预览数据
|
||||
const previewData = useMemo(() => {
|
||||
const validation = validateJson(editingJsonText)
|
||||
if (validation.valid && validation.parsed) {
|
||||
return { success: true, data: validation.parsed }
|
||||
}
|
||||
return { success: false, data: {} }
|
||||
}, [editingJsonText])
|
||||
|
||||
// 切换模式时同步数据
|
||||
const handleModeChange = useCallback((newMode: string) => {
|
||||
const targetMode = newMode as 'list' | 'json'
|
||||
if (targetMode === 'json' && mode === 'list') {
|
||||
// 从列表模式切换到 JSON 模式:将当前value转换为JSON
|
||||
setEditingJsonText(Object.keys(value).length > 0 ? JSON.stringify(value, null, 2) : '')
|
||||
setJsonError(null)
|
||||
}
|
||||
setMode(targetMode)
|
||||
}, [mode, value])
|
||||
|
||||
// JSON 文本变化
|
||||
const handleJsonChange = useCallback((text: string) => {
|
||||
setEditingJsonText(text)
|
||||
const validation = validateJson(text)
|
||||
if (validation.valid && validation.parsed) {
|
||||
setJsonError(null)
|
||||
onChange(validation.parsed)
|
||||
} else {
|
||||
setJsonError(validation.error || 'JSON 格式错误')
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<div className={cn("h-full flex flex-col", className)}>
|
||||
<Tabs value={mode} onValueChange={handleModeChange} className="w-full flex-1 flex flex-col">
|
||||
<TabsList className="h-8 p-0.5 bg-muted/60 w-fit">
|
||||
<TabsTrigger
|
||||
value="list"
|
||||
className="h-7 px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
可视化编辑
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="json"
|
||||
className="h-7 px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
JSON 编辑
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 可视化编辑模式(嵌套键值对) */}
|
||||
<TabsContent
|
||||
value="list"
|
||||
className="mt-2 flex-1 flex flex-col overflow-hidden data-[state=inactive]:hidden data-[state=inactive]:h-0"
|
||||
>
|
||||
<NestedKeyValueEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON 编辑模式 - 左右分栏 */}
|
||||
<TabsContent
|
||||
value="json"
|
||||
className="mt-2 flex-1 flex flex-col overflow-hidden data-[state=inactive]:hidden data-[state=inactive]:h-0"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 overflow-hidden">
|
||||
{/* 左侧:JSON 编辑器 */}
|
||||
<div className="flex flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">编辑</span>
|
||||
{jsonError ? (
|
||||
<div className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="truncate max-w-[150px]">{jsonError}</span>
|
||||
</div>
|
||||
) : editingJsonText.trim() && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<Check className="h-3 w-3" />
|
||||
<span>有效</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
value={editingJsonText}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
placeholder={'{\n "key": "value"\n}'}
|
||||
className={cn(
|
||||
"font-mono text-sm flex-1 resize-none",
|
||||
jsonError && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持任意 JSON 类型(包括嵌套对象和数组)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 右侧:预览 */}
|
||||
<div className="flex flex-col gap-2 overflow-hidden">
|
||||
<span className="text-xs text-muted-foreground">预览</span>
|
||||
<div className="flex-1 rounded-md border bg-muted/30 p-3 overflow-auto">
|
||||
{previewData.success && Object.keys(previewData.data).length > 0 ? (
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(previewData.data, null, 2)}
|
||||
</pre>
|
||||
) : previewData.success ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
暂无参数
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-sm text-destructive">
|
||||
JSON 格式错误
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
实时预览解析结果
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
dashboard/src/components/ui/label.tsx
Normal file
24
dashboard/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as 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.ElementRef<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 }
|
||||
28
dashboard/src/components/ui/markdown.tsx
Normal file
28
dashboard/src/components/ui/markdown.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 组件 - 用于渲染 Markdown 内容(支持 GFM 和 LaTeX)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Markdown>
|
||||
* # 标题
|
||||
* 这是一段 **加粗** 的文字
|
||||
*
|
||||
* 数学公式:$E = mc^2$
|
||||
*
|
||||
* 块级公式:
|
||||
* $$
|
||||
* \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}
|
||||
* $$
|
||||
* </Markdown>
|
||||
* ```
|
||||
*/
|
||||
export function Markdown({ children, className }: MarkdownProps) {
|
||||
return <MarkdownRenderer content={children} className={className} />
|
||||
}
|
||||
259
dashboard/src/components/ui/multi-select.tsx
Normal file
259
dashboard/src/components/ui/multi-select.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 多选下拉框组件
|
||||
* 支持搜索、单击选择、标签展示、拖动排序
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
import { X, Check, ChevronsUpDown, GripVertical } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
horizontalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
export interface MultiSelectOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: MultiSelectOption[]
|
||||
selected: string[]
|
||||
onChange: (values: string[]) => void
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// 可排序的标签组件
|
||||
function SortableBadge({
|
||||
value,
|
||||
label,
|
||||
onRemove,
|
||||
}: {
|
||||
value: string
|
||||
label: string
|
||||
onRemove: (value: string) => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: value })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
// 处理删除按钮点击,阻止事件冒泡和默认行为
|
||||
const handleRemoveClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onRemove(value)
|
||||
}
|
||||
|
||||
// 阻止删除按钮上的指针事件被 DndContext 捕获
|
||||
const handleRemovePointerDown = (e: React.PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1',
|
||||
isDragging && 'shadow-lg'
|
||||
)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-move hover:bg-secondary/80 flex items-center gap-1"
|
||||
>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing flex items-center"
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="ml-1 rounded-sm hover:bg-destructive/20 focus:outline-none focus:ring-1 focus:ring-destructive cursor-pointer"
|
||||
onClick={handleRemoveClick}
|
||||
onPointerDown={handleRemovePointerDown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleRemoveClick(e as any)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X
|
||||
className="h-3 w-3 hover:text-destructive"
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder = '选择选项...',
|
||||
emptyText = '未找到选项',
|
||||
className,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // 拖动至少8px才触发,避免与点击冲突
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (selected.includes(value)) {
|
||||
// 取消选择
|
||||
onChange(selected.filter((item) => item !== value))
|
||||
} else {
|
||||
// 添加选择
|
||||
onChange([...selected, value])
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = (value: string) => {
|
||||
onChange(selected.filter((item) => item !== value))
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selected.indexOf(active.id as string)
|
||||
const newIndex = selected.indexOf(over.id as string)
|
||||
|
||||
onChange(arrayMove(selected, oldIndex, newIndex))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('w-full justify-between min-h-10 h-auto', className)}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selected}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<div className="flex gap-1 flex-wrap flex-1">
|
||||
{selected.length === 0 ? (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
) : (
|
||||
selected.map((value) => {
|
||||
const option = options.find((opt) => opt.value === value)
|
||||
return (
|
||||
<SortableBadge
|
||||
key={value}
|
||||
value={value}
|
||||
label={option?.label || value}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" strokeWidth={2} fill="none" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="搜索..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selected.includes(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<Check className="h-3 w-3" strokeWidth={2} fill="none" />
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
475
dashboard/src/components/ui/nested-key-value-editor.tsx
Normal file
475
dashboard/src/components/ui/nested-key-value-editor.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { Plus, Trash2, ChevronRight, ChevronDown } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
// 生成唯一 ID
|
||||
function generateId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 11)}`
|
||||
}
|
||||
|
||||
type ValueType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'
|
||||
|
||||
interface TreeNode {
|
||||
id: string
|
||||
key: string
|
||||
value: unknown
|
||||
type: ValueType
|
||||
expanded?: boolean
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
interface NestedKeyValueEditorProps {
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// 推断值的类型
|
||||
function inferType(value: unknown): ValueType {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
if (typeof value === 'object') return 'object'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'number') return 'number'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
// 将 Record 转换为树节点数组
|
||||
function recordToTree(record: Record<string, unknown>): TreeNode[] {
|
||||
return Object.entries(record).map(([key, value]) => {
|
||||
const type = inferType(value)
|
||||
const node: TreeNode = {
|
||||
id: generateId(),
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
expanded: true,
|
||||
}
|
||||
|
||||
if (type === 'object' && value && typeof value === 'object') {
|
||||
node.children = recordToTree(value as Record<string, unknown>)
|
||||
} else if (type === 'array' && Array.isArray(value)) {
|
||||
node.children = value.map((item, index) => {
|
||||
const itemType = inferType(item)
|
||||
const childNode: TreeNode = {
|
||||
id: generateId(),
|
||||
key: String(index),
|
||||
value: item,
|
||||
type: itemType,
|
||||
expanded: true,
|
||||
}
|
||||
if (itemType === 'object' && item && typeof item === 'object') {
|
||||
childNode.children = recordToTree(item as Record<string, unknown>)
|
||||
} else if (itemType === 'array' && Array.isArray(item)) {
|
||||
childNode.children = item.map((subItem, subIndex) => ({
|
||||
id: generateId(),
|
||||
key: String(subIndex),
|
||||
value: subItem,
|
||||
type: inferType(subItem),
|
||||
expanded: true,
|
||||
}))
|
||||
}
|
||||
return childNode
|
||||
})
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
// 将树节点数组转换为 Record
|
||||
function treeToRecord(nodes: TreeNode[]): Record<string, unknown> {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (const node of nodes) {
|
||||
if (!node.key.trim()) continue
|
||||
|
||||
if (node.type === 'object' && node.children) {
|
||||
record[node.key] = treeToRecord(node.children)
|
||||
} else if (node.type === 'array' && node.children) {
|
||||
record[node.key] = node.children.map(child => {
|
||||
if (child.type === 'object' && child.children) {
|
||||
return treeToRecord(child.children)
|
||||
} else if (child.type === 'array' && child.children) {
|
||||
return child.children.map(c => c.value)
|
||||
}
|
||||
return child.value
|
||||
})
|
||||
} else if (node.type === 'null') {
|
||||
record[node.key] = null
|
||||
} else {
|
||||
record[node.key] = node.value
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
// 转换简单值
|
||||
function convertSimpleValue(value: string, type: ValueType): unknown {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return value === 'true'
|
||||
case 'number': {
|
||||
const num = parseFloat(value)
|
||||
return isNaN(num) ? 0 : num
|
||||
}
|
||||
case 'null':
|
||||
return null
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// 树节点组件
|
||||
function TreeNodeItem({
|
||||
node,
|
||||
level,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onAddChild,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
node: TreeNode
|
||||
level: number
|
||||
onUpdate: (id: string, field: 'key' | 'value' | 'type', value: unknown) => void
|
||||
onRemove: (id: string) => void
|
||||
onAddChild: (parentId: string) => void
|
||||
onToggleExpand: (id: string) => void
|
||||
}) {
|
||||
const isContainer = node.type === 'object' || node.type === 'array'
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
className="grid gap-2 items-center"
|
||||
style={{
|
||||
gridTemplateColumns: isContainer
|
||||
? '32px 1fr 90px 64px'
|
||||
: '32px 1fr 1fr 90px 32px',
|
||||
paddingLeft: `${level * 20}px`,
|
||||
}}
|
||||
>
|
||||
{/* 展开/折叠按钮 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
disabled={!isContainer || !hasChildren}
|
||||
>
|
||||
{isContainer && hasChildren ? (
|
||||
node.expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 键名 */}
|
||||
<Input
|
||||
value={node.key}
|
||||
onChange={(e) => onUpdate(node.id, 'key', e.target.value)}
|
||||
placeholder="key"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
|
||||
{/* 值(仅简单类型显示) */}
|
||||
{!isContainer && (
|
||||
<>
|
||||
{node.type === 'boolean' ? (
|
||||
<div className="flex items-center h-8 px-3 border rounded-md bg-background">
|
||||
<Switch
|
||||
checked={node.value === true}
|
||||
onCheckedChange={(checked) => onUpdate(node.id, 'value', checked)}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{node.value ? 'true' : 'false'}
|
||||
</span>
|
||||
</div>
|
||||
) : node.type === 'null' ? (
|
||||
<div className="flex items-center h-8 px-3 border rounded-md bg-muted text-sm text-muted-foreground">
|
||||
null
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={node.type === 'number' ? 'number' : 'text'}
|
||||
value={node.value as string | number}
|
||||
onChange={(e) => onUpdate(node.id, 'value', e.target.value)}
|
||||
placeholder="value"
|
||||
className="h-8 text-sm"
|
||||
step={node.type === 'number' ? 'any' : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 类型选择 */}
|
||||
<Select
|
||||
value={node.type}
|
||||
onValueChange={(v) => onUpdate(node.id, 'type', v as ValueType)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">字符串</SelectItem>
|
||||
<SelectItem value="number">数字</SelectItem>
|
||||
<SelectItem value="boolean">布尔</SelectItem>
|
||||
<SelectItem value="null">Null</SelectItem>
|
||||
<SelectItem value="object">对象</SelectItem>
|
||||
<SelectItem value="array">数组</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-1 justify-end">
|
||||
{isContainer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||
onClick={() => onAddChild(node.id)}
|
||||
title="添加子项"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onRemove(node.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 子节点 */}
|
||||
{isContainer && node.expanded && node.children && node.children.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{node.children.map((child) => (
|
||||
<TreeNodeItem
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={onRemove}
|
||||
onAddChild={onAddChild}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NestedKeyValueEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "添加参数...",
|
||||
}: NestedKeyValueEditorProps) {
|
||||
const [nodes, setNodes] = useState<TreeNode[]>(() => recordToTree(value || {}))
|
||||
|
||||
// 同步到父组件
|
||||
const syncToParent = useCallback(
|
||||
(newNodes: TreeNode[]) => {
|
||||
setNodes(newNodes)
|
||||
onChange(treeToRecord(newNodes))
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
// 添加根节点
|
||||
const addRootNode = useCallback(() => {
|
||||
const newNode: TreeNode = {
|
||||
id: generateId(),
|
||||
key: '',
|
||||
value: '',
|
||||
type: 'string',
|
||||
expanded: false,
|
||||
}
|
||||
syncToParent([...nodes, newNode])
|
||||
}, [nodes, syncToParent])
|
||||
|
||||
// 更新节点
|
||||
const updateNode = useCallback(
|
||||
(id: string, field: 'key' | 'value' | 'type', newValue: unknown) => {
|
||||
const updateRecursive = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === id) {
|
||||
if (field === 'type') {
|
||||
const newType = newValue as ValueType
|
||||
if (newType === 'object') {
|
||||
return { ...node, type: newType, value: {}, children: [] }
|
||||
} else if (newType === 'array') {
|
||||
return { ...node, type: newType, value: [], children: [] }
|
||||
} else if (newType === 'null') {
|
||||
return { ...node, type: newType, value: null }
|
||||
} else {
|
||||
const converted = convertSimpleValue(String(node.value), newType)
|
||||
return { ...node, type: newType, value: converted, children: undefined }
|
||||
}
|
||||
} else if (field === 'value') {
|
||||
const converted = convertSimpleValue(String(newValue), node.type)
|
||||
return { ...node, value: converted }
|
||||
} else {
|
||||
return { ...node, [field]: String(newValue) }
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
return { ...node, children: updateRecursive(node.children) }
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
syncToParent(updateRecursive(nodes))
|
||||
},
|
||||
[nodes, syncToParent]
|
||||
)
|
||||
|
||||
// 删除节点
|
||||
const removeNode = useCallback(
|
||||
(id: string) => {
|
||||
const removeRecursive = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return nodes
|
||||
.filter((node) => node.id !== id)
|
||||
.map((node) => {
|
||||
if (node.children) {
|
||||
return { ...node, children: removeRecursive(node.children) }
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
syncToParent(removeRecursive(nodes))
|
||||
},
|
||||
[nodes, syncToParent]
|
||||
)
|
||||
|
||||
// 添加子节点
|
||||
const addChildNode = useCallback(
|
||||
(parentId: string) => {
|
||||
const addRecursive = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
const newChild: TreeNode = {
|
||||
id: generateId(),
|
||||
key: node.type === 'array' ? String(node.children?.length || 0) : '',
|
||||
value: '',
|
||||
type: 'string',
|
||||
expanded: true,
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children: [...(node.children || []), newChild],
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
return { ...node, children: addRecursive(node.children) }
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
syncToParent(addRecursive(nodes))
|
||||
},
|
||||
[nodes, syncToParent]
|
||||
)
|
||||
|
||||
// 切换展开/折叠
|
||||
const toggleExpand = useCallback(
|
||||
(id: string) => {
|
||||
const toggleRecursive = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === id) {
|
||||
return { ...node, expanded: !node.expanded }
|
||||
}
|
||||
if (node.children) {
|
||||
return { ...node, children: toggleRecursive(node.children) }
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
setNodes(toggleRecursive(nodes))
|
||||
},
|
||||
[nodes]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{nodes.length} 个参数
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addRootNode}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
添加参数
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-1">
|
||||
{nodes.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-4 border border-dashed rounded-md">
|
||||
{placeholder}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* 表头 */}
|
||||
<div
|
||||
className="grid gap-2 text-xs text-muted-foreground px-1 sticky top-0 bg-background z-10"
|
||||
style={{
|
||||
gridTemplateColumns: '32px 1fr 1fr 90px 32px',
|
||||
}}
|
||||
>
|
||||
<span></span>
|
||||
<span>键名</span>
|
||||
<span>值</span>
|
||||
<span>类型</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{nodes.map((node) => (
|
||||
<TreeNodeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onUpdate={updateNode}
|
||||
onRemove={removeNode}
|
||||
onAddChild={addChildNode}
|
||||
onToggleExpand={toggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
dashboard/src/components/ui/pagination.tsx
Normal file
117
dashboard/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>上一页</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>下一页</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
31
dashboard/src/components/ui/popover.tsx
Normal file
31
dashboard/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[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 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
26
dashboard/src/components/ui/progress.tsx
Normal file
26
dashboard/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
41
dashboard/src/components/ui/radio-group.tsx
Normal file
41
dashboard/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
51
dashboard/src/components/ui/scroll-area.tsx
Normal file
51
dashboard/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
|
||||
viewportRef?: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
ScrollAreaProps
|
||||
>(({ className, children, viewportRef, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<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 }
|
||||
158
dashboard/src/components/ui/select.tsx
Normal file
158
dashboard/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-[100] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-hidden rounded-md border border-border bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 shadow-lg 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-[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 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-2 pl-2 pr-8 text-sm outline-none bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-gray-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
29
dashboard/src/components/ui/separator.tsx
Normal file
29
dashboard/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<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 }
|
||||
15
dashboard/src/components/ui/skeleton.tsx
Normal file
15
dashboard/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
26
dashboard/src/components/ui/slider.tsx
Normal file
26
dashboard/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
27
dashboard/src/components/ui/switch.tsx
Normal file
27
dashboard/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
120
dashboard/src/components/ui/table.tsx
Normal file
120
dashboard/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-4 py-3 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
55
dashboard/src/components/ui/tabs.tsx
Normal file
55
dashboard/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
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.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<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 text-sm font-medium ring-offset-background transition-all duration-300 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<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 data-[state=active]:animate-in data-[state=active]:fade-in data-[state=active]:duration-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
110
dashboard/src/components/ui/textarea.tsx
Normal file
110
dashboard/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends React.ComponentProps<"textarea"> {
|
||||
/**
|
||||
* 是否启用自动高度调整
|
||||
* @default true
|
||||
*/
|
||||
autoResize?: boolean
|
||||
/**
|
||||
* 最小高度(像素),仅在 autoResize=true 时生效
|
||||
* @default 60
|
||||
*/
|
||||
minHeight?: number
|
||||
/**
|
||||
* 最大高度(像素),仅在 autoResize=true 时生效
|
||||
* 设置为 undefined 或 0 表示不限制最大高度
|
||||
*/
|
||||
maxHeight?: number
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, autoResize = true, minHeight = 60, maxHeight, value, onChange, ...props }, ref) => {
|
||||
const innerRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
const [hasFixedHeight, setHasFixedHeight] = React.useState(false)
|
||||
|
||||
// 合并 ref
|
||||
React.useImperativeHandle(ref, () => innerRef.current!)
|
||||
|
||||
// 检测是否设置了固定高度
|
||||
React.useEffect(() => {
|
||||
if (className) {
|
||||
// 检查是否包含固定高度的类(如 h-20, h-[200px], min-h-[xxx] 等)
|
||||
const hasFixedHeightClass = /\b(h-\d+|h-\[[\d.]+(?:px|rem|em)\]|min-h-\[[\d.]+(?:px|rem|em)\])\b/.test(className)
|
||||
setHasFixedHeight(hasFixedHeightClass)
|
||||
}
|
||||
}, [className])
|
||||
|
||||
// 自动调整高度函数
|
||||
const adjustHeight = React.useCallback(() => {
|
||||
const textarea = innerRef.current
|
||||
if (!textarea || !autoResize || hasFixedHeight) return
|
||||
|
||||
// 重置高度以获取真实的 scrollHeight
|
||||
textarea.style.height = 'auto'
|
||||
|
||||
// 计算新高度
|
||||
const scrollHeight = textarea.scrollHeight
|
||||
let newHeight = Math.max(scrollHeight, minHeight)
|
||||
|
||||
// 应用最大高度限制
|
||||
if (maxHeight && maxHeight > 0) {
|
||||
newHeight = Math.min(newHeight, maxHeight)
|
||||
}
|
||||
|
||||
textarea.style.height = `${newHeight}px`
|
||||
|
||||
// 如果内容超过最大高度,启用滚动
|
||||
if (maxHeight && maxHeight > 0 && scrollHeight > maxHeight) {
|
||||
textarea.style.overflowY = 'auto'
|
||||
} else {
|
||||
textarea.style.overflowY = 'hidden'
|
||||
}
|
||||
}, [autoResize, hasFixedHeight, minHeight, maxHeight])
|
||||
|
||||
// 监听 value 变化并调整高度
|
||||
React.useEffect(() => {
|
||||
adjustHeight()
|
||||
}, [value, adjustHeight])
|
||||
|
||||
// 组件挂载时调整高度
|
||||
React.useEffect(() => {
|
||||
adjustHeight()
|
||||
}, [adjustHeight])
|
||||
|
||||
// 处理 onChange 事件
|
||||
const handleChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange?.(e)
|
||||
// 延迟调整高度,确保值已更新
|
||||
requestAnimationFrame(() => {
|
||||
adjustHeight()
|
||||
})
|
||||
},
|
||||
[onChange, adjustHeight]
|
||||
)
|
||||
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"custom-scrollbar",
|
||||
autoResize && !hasFixedHeight && "resize-none overflow-hidden",
|
||||
className
|
||||
)}
|
||||
ref={innerRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
style={{
|
||||
minHeight: autoResize && !hasFixedHeight ? `${minHeight}px` : undefined,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
142
dashboard/src/components/ui/toast.tsx
Normal file
142
dashboard/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { X } from "lucide-react"
|
||||
import { useIsMobile } from "@/hooks/use-media-query"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-[100] flex max-h-screen w-full gap-2 p-4",
|
||||
isMobile
|
||||
? "top-0 left-0 right-0 flex-col items-center"
|
||||
: "bottom-0 right-0 flex-col-reverse sm:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-primary/5 text-foreground backdrop-blur-sm",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive/10 text-destructive-foreground backdrop-blur-sm",
|
||||
},
|
||||
position: {
|
||||
desktop: "data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-slide-in-from-right data-[state=open]:animate-fade-in data-[state=closed]:animate-slide-out-to-right data-[state=closed]:animate-fade-out data-[swipe=end]:animate-slide-out-to-right",
|
||||
mobile: "data-[swipe=cancel]:translate-y-0 data-[swipe=end]:translate-y-[var(--radix-toast-swipe-end-y)] data-[swipe=move]:translate-y-[var(--radix-toast-swipe-move-y)] data-[swipe=move]:transition-none data-[state=open]:animate-slide-in-from-top data-[state=open]:animate-fade-in data-[state=closed]:animate-slide-out-to-top data-[state=closed]:animate-fade-out data-[swipe=end]:animate-slide-out-to-top",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
position: "desktop",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
const isMobile = useIsMobile()
|
||||
const position = isMobile ? "mobile" : "desktop"
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant, position }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
35
dashboard/src/components/ui/toaster.tsx
Normal file
35
dashboard/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useIsMobile } from "@/hooks/use-media-query"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<ToastProvider swipeDirection={isMobile ? "up" : "right"}>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
30
dashboard/src/components/ui/tooltip.tsx
Normal file
30
dashboard/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground 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 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
47
dashboard/src/components/use-theme.tsx
Normal file
47
dashboard/src/components/use-theme.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useContext } from 'react'
|
||||
import { ThemeProviderContext } from '@/lib/theme-context'
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export const toggleThemeWithTransition = (
|
||||
theme: 'dark' | 'light' | 'system',
|
||||
setTheme: (theme: 'dark' | 'light' | 'system') => void,
|
||||
event: React.MouseEvent
|
||||
) => {
|
||||
// 检查是否禁用动画
|
||||
const animationsDisabled = document.documentElement.classList.contains('no-animations')
|
||||
|
||||
// 检查浏览器是否支持 View Transitions API
|
||||
if (!document.startViewTransition || animationsDisabled) {
|
||||
setTheme(theme)
|
||||
return
|
||||
}
|
||||
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
setTheme(theme)
|
||||
})
|
||||
|
||||
transition.ready.then(() => {
|
||||
// 始终在新内容层应用动画(z-index: 999)
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`],
|
||||
},
|
||||
{
|
||||
duration: 500,
|
||||
easing: 'ease-in-out',
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
382
dashboard/src/components/waves-background.tsx
Normal file
382
dashboard/src/components/waves-background.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// 生成一个固定的随机种子(在模块加载时生成一次)
|
||||
const NOISE_SEED = (() => {
|
||||
// 使用时间戳的一部分作为种子,但在开发环境中使用固定值以保持一致性
|
||||
if (import.meta.env.DEV) {
|
||||
return 42 // 开发环境使用固定种子
|
||||
}
|
||||
return Date.now() % 1000000
|
||||
})()
|
||||
|
||||
// Perlin Noise implementation
|
||||
class Noise {
|
||||
private grad3: number[][]
|
||||
private p: number[]
|
||||
private perm: number[]
|
||||
|
||||
constructor(seed = 0) {
|
||||
// Use seed to ensure deterministic noise (seed is used implicitly in shuffle)
|
||||
void seed
|
||||
this.grad3 = [
|
||||
[1, 1, 0],
|
||||
[-1, 1, 0],
|
||||
[1, -1, 0],
|
||||
[-1, -1, 0],
|
||||
[1, 0, 1],
|
||||
[-1, 0, 1],
|
||||
[1, 0, -1],
|
||||
[-1, 0, -1],
|
||||
[0, 1, 1],
|
||||
[0, -1, 1],
|
||||
[0, 1, -1],
|
||||
[0, -1, -1],
|
||||
]
|
||||
this.p = []
|
||||
for (let i = 0; i < 256; i++) {
|
||||
this.p[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
this.perm = []
|
||||
for (let i = 0; i < 512; i++) {
|
||||
this.perm[i] = this.p[i & 255]
|
||||
}
|
||||
}
|
||||
|
||||
dot(g: number[], x: number, y: number) {
|
||||
return g[0] * x + g[1] * y
|
||||
}
|
||||
|
||||
mix(a: number, b: number, t: number) {
|
||||
return (1 - t) * a + t * b
|
||||
}
|
||||
|
||||
fade(t: number) {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10)
|
||||
}
|
||||
|
||||
perlin2(x: number, y: number) {
|
||||
const X = Math.floor(x) & 255
|
||||
const Y = Math.floor(y) & 255
|
||||
x -= Math.floor(x)
|
||||
y -= Math.floor(y)
|
||||
const u = this.fade(x)
|
||||
const v = this.fade(y)
|
||||
const A = this.perm[X] + Y
|
||||
const AA = this.perm[A]
|
||||
const AB = this.perm[A + 1]
|
||||
const B = this.perm[X + 1] + Y
|
||||
const BA = this.perm[B]
|
||||
const BB = this.perm[B + 1]
|
||||
|
||||
return this.mix(
|
||||
this.mix(
|
||||
this.dot(this.grad3[AA % 12], x, y),
|
||||
this.dot(this.grad3[BA % 12], x - 1, y),
|
||||
u
|
||||
),
|
||||
this.mix(
|
||||
this.dot(this.grad3[AB % 12], x, y - 1),
|
||||
this.dot(this.grad3[BB % 12], x - 1, y - 1),
|
||||
u
|
||||
),
|
||||
v
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
wave: { x: number; y: number }
|
||||
cursor: { x: number; y: number; vx: number; vy: number }
|
||||
}
|
||||
|
||||
export function WavesBackground() {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const animationRef = useRef<number | undefined>(undefined)
|
||||
const [noiseInstance] = useState(() => new Noise(NOISE_SEED))
|
||||
|
||||
const dataRef = useRef<{
|
||||
mouse: {
|
||||
x: number
|
||||
y: number
|
||||
lx: number
|
||||
ly: number
|
||||
sx: number
|
||||
sy: number
|
||||
v: number
|
||||
vs: number
|
||||
a: number
|
||||
set: boolean
|
||||
}
|
||||
lines: Point[][]
|
||||
paths: SVGPathElement[]
|
||||
noise: Noise
|
||||
bounding: DOMRect | null
|
||||
}>({
|
||||
mouse: {
|
||||
x: -10,
|
||||
y: 0,
|
||||
lx: 0,
|
||||
ly: 0,
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
v: 0,
|
||||
vs: 0,
|
||||
a: 0,
|
||||
set: false,
|
||||
},
|
||||
lines: [],
|
||||
paths: [],
|
||||
noise: noiseInstance,
|
||||
bounding: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const svg = svgRef.current
|
||||
if (!container || !svg) return
|
||||
|
||||
const data = dataRef.current
|
||||
// 将 noiseInstance 赋值给 dataRef
|
||||
data.noise = noiseInstance
|
||||
|
||||
// Set size
|
||||
const setSize = () => {
|
||||
const bounding = container.getBoundingClientRect()
|
||||
data.bounding = bounding
|
||||
svg.style.width = `${bounding.width}px`
|
||||
svg.style.height = `${bounding.height}px`
|
||||
}
|
||||
|
||||
// Set lines
|
||||
const setLines = () => {
|
||||
if (!data.bounding) return
|
||||
|
||||
const { width, height } = data.bounding
|
||||
|
||||
data.lines = []
|
||||
data.paths.forEach((path) => path.remove())
|
||||
data.paths = []
|
||||
|
||||
const xGap = 10
|
||||
const yGap = 32
|
||||
|
||||
const oWidth = width + 200
|
||||
const oHeight = height + 30
|
||||
|
||||
const totalLines = Math.ceil(oWidth / xGap)
|
||||
const totalPoints = Math.ceil(oHeight / yGap)
|
||||
|
||||
const xStart = (width - xGap * totalLines) / 2
|
||||
const yStart = (height - yGap * totalPoints) / 2
|
||||
|
||||
for (let i = 0; i <= totalLines; i++) {
|
||||
const points: Point[] = []
|
||||
|
||||
for (let j = 0; j <= totalPoints; j++) {
|
||||
const point: Point = {
|
||||
x: xStart + xGap * i,
|
||||
y: yStart + yGap * j,
|
||||
wave: { x: 0, y: 0 },
|
||||
cursor: { x: 0, y: 0, vx: 0, vy: 0 },
|
||||
}
|
||||
points.push(point)
|
||||
}
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
svg.appendChild(path)
|
||||
data.paths.push(path)
|
||||
data.lines.push(points)
|
||||
}
|
||||
}
|
||||
|
||||
// Move points
|
||||
const movePoints = (time: number) => {
|
||||
const { lines, mouse, noise } = data
|
||||
|
||||
lines.forEach((points) => {
|
||||
points.forEach((p) => {
|
||||
// Wave movement
|
||||
const move =
|
||||
noise.perlin2((p.x + time * 0.0125) * 0.002, (p.y + time * 0.005) * 0.0015) * 12
|
||||
p.wave.x = Math.cos(move) * 32
|
||||
p.wave.y = Math.sin(move) * 16
|
||||
|
||||
// Mouse effect
|
||||
const dx = p.x - mouse.sx
|
||||
const dy = p.y - mouse.sy
|
||||
const d = Math.hypot(dx, dy)
|
||||
const l = Math.max(175, mouse.vs)
|
||||
|
||||
if (d < l) {
|
||||
const s = 1 - d / l
|
||||
const f = Math.cos(d * 0.001) * s
|
||||
|
||||
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065
|
||||
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065
|
||||
}
|
||||
|
||||
p.cursor.vx += (0 - p.cursor.x) * 0.005
|
||||
p.cursor.vy += (0 - p.cursor.y) * 0.005
|
||||
|
||||
p.cursor.vx *= 0.925
|
||||
p.cursor.vy *= 0.925
|
||||
|
||||
p.cursor.x += p.cursor.vx * 2
|
||||
p.cursor.y += p.cursor.vy * 2
|
||||
|
||||
p.cursor.x = Math.min(100, Math.max(-100, p.cursor.x))
|
||||
p.cursor.y = Math.min(100, Math.max(-100, p.cursor.y))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Get moved point
|
||||
const moved = (point: Point, withCursorForce = true) => {
|
||||
const coords = {
|
||||
x: point.x + point.wave.x + (withCursorForce ? point.cursor.x : 0),
|
||||
y: point.y + point.wave.y + (withCursorForce ? point.cursor.y : 0),
|
||||
}
|
||||
coords.x = Math.round(coords.x * 10) / 10
|
||||
coords.y = Math.round(coords.y * 10) / 10
|
||||
return coords
|
||||
}
|
||||
|
||||
// Draw lines
|
||||
const drawLines = () => {
|
||||
const { lines, paths } = data
|
||||
|
||||
lines.forEach((points, lIndex) => {
|
||||
let p1 = moved(points[0], false)
|
||||
let d = `M ${p1.x} ${p1.y}`
|
||||
|
||||
points.forEach((point, pIndex) => {
|
||||
const isLast = pIndex === points.length - 1
|
||||
p1 = moved(point, !isLast)
|
||||
d += `L ${p1.x} ${p1.y}`
|
||||
})
|
||||
|
||||
paths[lIndex].setAttribute('d', d)
|
||||
})
|
||||
}
|
||||
|
||||
// Tick
|
||||
const tick = (time: number) => {
|
||||
const { mouse } = data
|
||||
|
||||
mouse.sx += (mouse.x - mouse.sx) * 0.1
|
||||
mouse.sy += (mouse.y - mouse.sy) * 0.1
|
||||
|
||||
const dx = mouse.x - mouse.lx
|
||||
const dy = mouse.y - mouse.ly
|
||||
const d = Math.hypot(dx, dy)
|
||||
|
||||
mouse.v = d
|
||||
mouse.vs += (d - mouse.vs) * 0.1
|
||||
mouse.vs = Math.min(100, mouse.vs)
|
||||
|
||||
mouse.lx = mouse.x
|
||||
mouse.ly = mouse.y
|
||||
|
||||
mouse.a = Math.atan2(dy, dx)
|
||||
|
||||
if (container) {
|
||||
container.style.setProperty('--x', `${mouse.sx}px`)
|
||||
container.style.setProperty('--y', `${mouse.sy}px`)
|
||||
}
|
||||
|
||||
movePoints(time)
|
||||
drawLines()
|
||||
|
||||
animationRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!data.bounding) return
|
||||
const { mouse } = data
|
||||
mouse.x = e.pageX - data.bounding.left
|
||||
mouse.y = e.pageY - data.bounding.top + window.scrollY
|
||||
|
||||
if (!mouse.set) {
|
||||
mouse.sx = mouse.x
|
||||
mouse.sy = mouse.y
|
||||
mouse.lx = mouse.x
|
||||
mouse.ly = mouse.y
|
||||
mouse.set = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
setSize()
|
||||
setLines()
|
||||
}
|
||||
|
||||
// Init
|
||||
setSize()
|
||||
setLines()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
animationRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [noiseInstance])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="waves-background"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="waves-cursor"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '0.5rem',
|
||||
height: '0.5rem',
|
||||
background: 'hsl(var(--primary) / 0.3)',
|
||||
borderRadius: '50%',
|
||||
transform: 'translate3d(calc(var(--x, -0.5rem) - 50%), calc(var(--y, 50%) - 50%), 0)',
|
||||
willChange: 'transform',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
path {
|
||||
fill: none;
|
||||
stroke: hsl(var(--primary) / 0.20);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
`}</style>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
dashboard/src/config/surveys/index.ts
Normal file
2
dashboard/src/config/surveys/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { webuiFeedbackSurvey } from './webui-feedback'
|
||||
export { maibotFeedbackSurvey } from './maibot-feedback'
|
||||
103
dashboard/src/config/surveys/maibot-feedback.ts
Normal file
103
dashboard/src/config/surveys/maibot-feedback.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { SurveyConfig } from '@/types/survey'
|
||||
|
||||
export const maibotFeedbackSurvey: SurveyConfig = {
|
||||
id: 'maibot-feedback-v1',
|
||||
version: '1.0.0',
|
||||
title: '麦麦使用体验反馈问卷',
|
||||
description: '感谢您使用麦麦!您的反馈将帮助我们打造更好的 AI 伙伴。',
|
||||
questions: [
|
||||
{
|
||||
id: 'maibot_version',
|
||||
type: 'text',
|
||||
title: '你正在使用的麦麦版本',
|
||||
description: '此项由系统自动填写',
|
||||
required: true,
|
||||
readOnly: true,
|
||||
placeholder: '自动检测中...',
|
||||
},
|
||||
{
|
||||
id: 'improvement_areas',
|
||||
type: 'textarea',
|
||||
title: '你认为麦麦还有哪些部分可以改进?',
|
||||
required: true,
|
||||
placeholder: '请分享你认为可以改进的方面...',
|
||||
maxLength: 1000,
|
||||
},
|
||||
{
|
||||
id: 'problems_encountered',
|
||||
type: 'multiple',
|
||||
title: '你在使用麦麦时遇到过哪些问题?',
|
||||
description: '可多选',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'incomplete', label: '功能不完整', value: 'incomplete' },
|
||||
{ id: 'slow_response', label: '响应速度慢', value: 'slow_response' },
|
||||
{ id: 'complex', label: '操作复杂', value: 'complex' },
|
||||
{ id: 'unstable', label: '运行不稳定', value: 'unstable' },
|
||||
{ id: 'config_difficult', label: '配置困难', value: 'config_difficult' },
|
||||
{ id: 'none', label: '没有遇到问题', value: 'none' },
|
||||
{ id: 'other', label: '其他', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'problems_other',
|
||||
type: 'text',
|
||||
title: '如选择"其他",请说明遇到的问题',
|
||||
required: false,
|
||||
placeholder: '请描述你遇到的其他问题...',
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
id: 'helpful_features',
|
||||
type: 'textarea',
|
||||
title: '你觉得麦麦的哪些功能对你最有帮助?',
|
||||
required: true,
|
||||
placeholder: '请分享对你最有帮助的功能...',
|
||||
maxLength: 1000,
|
||||
},
|
||||
{
|
||||
id: 'feature_requests',
|
||||
type: 'textarea',
|
||||
title: '你希望在未来的版本中增加哪些功能?',
|
||||
required: true,
|
||||
placeholder: '请告诉我们你期望的新功能...',
|
||||
maxLength: 1000,
|
||||
},
|
||||
{
|
||||
id: 'overall_satisfaction',
|
||||
type: 'single',
|
||||
title: '你对麦麦的整体满意度如何?',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
|
||||
{ id: 'satisfied', label: '满意', value: 'satisfied' },
|
||||
{ id: 'neutral', label: '一般', value: 'neutral' },
|
||||
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
|
||||
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'would_recommend',
|
||||
type: 'single',
|
||||
title: '你愿意推荐麦麦给其他人使用吗?',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'yes', label: '是', value: 'yes' },
|
||||
{ id: 'no', label: '否', value: 'no' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'other_suggestions',
|
||||
type: 'textarea',
|
||||
title: '其他建议或意见',
|
||||
description: '此项为选填',
|
||||
required: false,
|
||||
placeholder: '如果你有任何其他想法或建议,请在此分享...',
|
||||
maxLength: 2000,
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
allowMultiple: false,
|
||||
thankYouMessage: '感谢你的反馈!你的意见对麦麦的成长非常重要,我们会认真考虑每一条建议。',
|
||||
},
|
||||
}
|
||||
107
dashboard/src/config/surveys/webui-feedback.ts
Normal file
107
dashboard/src/config/surveys/webui-feedback.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { SurveyConfig } from '@/types/survey'
|
||||
|
||||
export const webuiFeedbackSurvey: SurveyConfig = {
|
||||
id: 'webui-feedback-v1',
|
||||
version: '1.0.0',
|
||||
title: '麦麦 WebUI 使用反馈问卷',
|
||||
description: '感谢您使用麦麦 WebUI!您的反馈将帮助我们不断改进产品体验。',
|
||||
questions: [
|
||||
{
|
||||
id: 'webui_version',
|
||||
type: 'text',
|
||||
title: '你正在使用的 WebUI 版本',
|
||||
description: '此项由系统自动填写',
|
||||
required: true,
|
||||
readOnly: true,
|
||||
placeholder: '自动检测中...',
|
||||
},
|
||||
{
|
||||
id: 'ui_design_satisfaction',
|
||||
type: 'single',
|
||||
title: '你觉得当前的 WebUI 界面设计如何?',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
|
||||
{ id: 'satisfied', label: '满意', value: 'satisfied' },
|
||||
{ id: 'neutral', label: '一般', value: 'neutral' },
|
||||
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
|
||||
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'problems_encountered',
|
||||
type: 'multiple',
|
||||
title: '你在使用 WebUI 时遇到过哪些问题?',
|
||||
description: '可多选',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'lag', label: '界面卡顿', value: 'lag' },
|
||||
{ id: 'incomplete', label: '功能不完整', value: 'incomplete' },
|
||||
{ id: 'complex', label: '操作复杂', value: 'complex' },
|
||||
{ id: 'bugs', label: '存在 Bug', value: 'bugs' },
|
||||
{ id: 'none', label: '没有遇到问题', value: 'none' },
|
||||
{ id: 'other', label: '其他', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'problems_other',
|
||||
type: 'text',
|
||||
title: '如选择"其他",请说明遇到的问题',
|
||||
required: false,
|
||||
placeholder: '请描述你遇到的其他问题...',
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
id: 'useful_features',
|
||||
type: 'textarea',
|
||||
title: '你觉得哪些功能是最有用的?',
|
||||
required: true,
|
||||
placeholder: '请分享你认为最有价值的功能...',
|
||||
maxLength: 1000,
|
||||
},
|
||||
{
|
||||
id: 'feature_requests',
|
||||
type: 'textarea',
|
||||
title: '你希望在未来的版本中增加哪些功能?',
|
||||
required: true,
|
||||
placeholder: '请告诉我们你期望的新功能...',
|
||||
maxLength: 1000,
|
||||
},
|
||||
{
|
||||
id: 'overall_satisfaction',
|
||||
type: 'single',
|
||||
title: '你对麦麦 WebUI 的整体满意度如何?',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'very_satisfied', label: '非常满意', value: 'very_satisfied' },
|
||||
{ id: 'satisfied', label: '满意', value: 'satisfied' },
|
||||
{ id: 'neutral', label: '一般', value: 'neutral' },
|
||||
{ id: 'dissatisfied', label: '不满意', value: 'dissatisfied' },
|
||||
{ id: 'very_dissatisfied', label: '非常不满意', value: 'very_dissatisfied' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'would_recommend',
|
||||
type: 'single',
|
||||
title: '你愿意推荐麦麦 WebUI 给其他人使用吗?',
|
||||
required: true,
|
||||
options: [
|
||||
{ id: 'yes', label: '是', value: 'yes' },
|
||||
{ id: 'no', label: '否', value: 'no' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'other_suggestions',
|
||||
type: 'textarea',
|
||||
title: '其他建议或意见',
|
||||
description: '此项为选填',
|
||||
required: false,
|
||||
placeholder: '如果你有任何其他想法或建议,请在此分享...',
|
||||
maxLength: 2000,
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
allowMultiple: false,
|
||||
thankYouMessage: '感谢你的反馈!你的意见对我们非常重要,我们会认真考虑每一条建议。',
|
||||
},
|
||||
}
|
||||
12
dashboard/src/hooks/use-animation.ts
Normal file
12
dashboard/src/hooks/use-animation.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useContext } from 'react'
|
||||
import { AnimationContext } from '@/lib/animation-context'
|
||||
|
||||
export const useAnimation = () => {
|
||||
const context = useContext(AnimationContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAnimation must be used within an AnimationProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
68
dashboard/src/hooks/use-auth.ts
Normal file
68
dashboard/src/hooks/use-auth.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { checkAuthStatus } from '@/lib/fetch-with-auth'
|
||||
|
||||
export function useAuthGuard() {
|
||||
const navigate = useNavigate()
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const verifyAuth = async () => {
|
||||
try {
|
||||
const isAuth = await checkAuthStatus()
|
||||
if (!cancelled && !isAuth) {
|
||||
navigate({ to: '/auth' })
|
||||
}
|
||||
} catch {
|
||||
// 发生错误时也跳转到登录页
|
||||
if (!cancelled) {
|
||||
navigate({ to: '/auth' })
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verifyAuth()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
return { checking }
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已认证(异步)
|
||||
*/
|
||||
export async function checkAuth(): Promise<boolean> {
|
||||
return await checkAuthStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要首次配置
|
||||
*/
|
||||
export async function checkFirstSetup(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/api/webui/setup/status', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
return data.is_first_setup
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('检查首次配置状态失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
35
dashboard/src/hooks/use-media-query.ts
Normal file
35
dashboard/src/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia(query).matches
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
setMatches(mediaQuery.matches)
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 768px)')
|
||||
}
|
||||
192
dashboard/src/hooks/use-toast.ts
Normal file
192
dashboard/src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 5
|
||||
const TOAST_REMOVE_DELAY = 5000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = {
|
||||
ADD_TOAST: "ADD_TOAST"
|
||||
UPDATE_TOAST: "UPDATE_TOAST"
|
||||
DISMISS_TOAST: "DISMISS_TOAST"
|
||||
REMOVE_TOAST: "REMOVE_TOAST"
|
||||
}
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
205
dashboard/src/index.css
Normal file
205
dashboard/src/index.css
Normal file
@@ -0,0 +1,205 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* JetBrains Mono 字体 - 用于代码编辑器 */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/JetBrainsMono-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--primary-gradient: none; /* 默认无渐变 */
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 221.2 83.2% 53.3%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--primary-gradient: none; /* 默认无渐变 */
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
--chart-1: 217.2 91.2% 59.8%;
|
||||
--chart-2: 160 60% 50%;
|
||||
--chart-3: 30 80% 60%;
|
||||
--chart-4: 280 65% 65%;
|
||||
--chart-5: 340 75% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* 隐藏数字输入框的默认上下箭头 */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 渐变色背景工具类 */
|
||||
.bg-primary-gradient {
|
||||
background: var(--primary-gradient, hsl(var(--primary)));
|
||||
}
|
||||
|
||||
/* 渐变色文字工具类 - 默认使用普通文字颜色 */
|
||||
.text-primary-gradient {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* 当应用了 has-gradient 类时,使用渐变文字效果 */
|
||||
.has-gradient .text-primary-gradient {
|
||||
background: var(--primary-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* 渐变色边框工具类 */
|
||||
.border-primary-gradient {
|
||||
border-image: var(--primary-gradient, linear-gradient(to right, hsl(var(--primary)), hsl(var(--primary)))) 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 禁用动效时的样式 */
|
||||
.no-animations *,
|
||||
.no-animations *::before,
|
||||
.no-animations *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* 保留基本的 hover 反馈 */
|
||||
.no-animations *:hover {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* View Transition API 动画 */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* 默认情况下(亮色→暗色),新内容在上层 */
|
||||
::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* React Joyride Tour 样式 - 确保在 Dialog 之上 */
|
||||
.__floater {
|
||||
z-index: 99999 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.react-joyride__overlay {
|
||||
z-index: 99998 !important;
|
||||
}
|
||||
|
||||
.react-joyride__spotlight {
|
||||
z-index: 99998 !important;
|
||||
}
|
||||
|
||||
/* Tour tooltip 内的按钮需要可点击 */
|
||||
.react-joyride__tooltip {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
#tour-portal-container * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
26
dashboard/src/main.tsx
Normal file
26
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import './index.css'
|
||||
import { router } from './router'
|
||||
import { ThemeProvider } from './components/theme-provider'
|
||||
import { AnimationProvider } from './components/animation-provider'
|
||||
import { TourProvider, TourRenderer } from './components/tour'
|
||||
import { Toaster } from './components/ui/toaster'
|
||||
import { ErrorBoundary } from './components/error-boundary'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<AnimationProvider>
|
||||
<TourProvider>
|
||||
<RouterProvider router={router} />
|
||||
<TourRenderer />
|
||||
<Toaster />
|
||||
</TourProvider>
|
||||
</AnimationProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
304
dashboard/src/router.tsx
Normal file
304
dashboard/src/router.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
import { IndexPage } from './routes/index'
|
||||
import { SettingsPage } from './routes/settings'
|
||||
import { AuthPage } from './routes/auth'
|
||||
import { SetupPage } from './routes/setup'
|
||||
import { NotFoundPage } from './routes/404'
|
||||
import { BotConfigPage } from './routes/config/bot'
|
||||
import { ModelProviderConfigPage } from './routes/config/modelProvider'
|
||||
import { ModelConfigPage } from './routes/config/model'
|
||||
import { AdapterConfigPage } from './routes/config/adapter'
|
||||
import { EmojiManagementPage } from './routes/resource/emoji'
|
||||
import { ExpressionManagementPage } from './routes/resource/expression'
|
||||
import { JargonManagementPage } from './routes/resource/jargon'
|
||||
import { PersonManagementPage } from './routes/person'
|
||||
import { KnowledgeGraphPage } from './routes/resource/knowledge-graph'
|
||||
import { KnowledgeBasePage } from './routes/resource/knowledge-base'
|
||||
import { LogViewerPage } from './routes/logs'
|
||||
import { PlannerMonitorPage } from './routes/monitor'
|
||||
import { PluginsPage } from './routes/plugins'
|
||||
import { ModelPresetsPage } from './routes/model-presets'
|
||||
import { PluginConfigPage } from './routes/plugin-config'
|
||||
import { PluginMirrorsPage } from './routes/plugin-mirrors'
|
||||
import { PluginDetailPage } from './routes/plugin-detail'
|
||||
import { ChatPage } from './routes/chat'
|
||||
import { WebUIFeedbackSurveyPage, MaiBotFeedbackSurveyPage } from './routes/survey'
|
||||
import { AnnualReportPage } from './routes/annual-report'
|
||||
import PackMarketPage from './routes/config/pack-market'
|
||||
import PackDetailPage from './routes/config/pack-detail'
|
||||
import { Layout } from './components/layout'
|
||||
import { checkAuth } from './hooks/use-auth'
|
||||
import { RouteErrorBoundary } from './components/error-boundary'
|
||||
|
||||
// Root 路由
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
{import.meta.env.DEV && <TanStackRouterDevtools />}
|
||||
</>
|
||||
),
|
||||
beforeLoad: () => {
|
||||
// 如果访问根路径且未认证,重定向到认证页面
|
||||
if (window.location.pathname === '/' && !checkAuth()) {
|
||||
throw redirect({ to: '/auth' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 认证路由(无 Layout)
|
||||
const authRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/auth',
|
||||
component: AuthPage,
|
||||
})
|
||||
|
||||
// 首次配置路由(无 Layout)
|
||||
const setupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup',
|
||||
component: SetupPage,
|
||||
})
|
||||
|
||||
// 受保护的路由 Root(带 Layout)
|
||||
const protectedRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'protected',
|
||||
component: () => (
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
),
|
||||
errorComponent: ({ error }) => <RouteErrorBoundary error={error} />,
|
||||
})
|
||||
|
||||
// 首页路由
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/',
|
||||
component: IndexPage,
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦主程序配置
|
||||
const botConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/bot',
|
||||
component: BotConfigPage,
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦模型提供商配置
|
||||
const modelProviderConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/modelProvider',
|
||||
component: ModelProviderConfigPage,
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦模型配置
|
||||
const modelConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/model',
|
||||
component: ModelConfigPage,
|
||||
})
|
||||
|
||||
// 配置路由 - 麦麦适配器配置
|
||||
const adapterConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/adapter',
|
||||
component: AdapterConfigPage,
|
||||
})
|
||||
|
||||
// 资源管理路由 - 表情包管理
|
||||
const emojiManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/emoji',
|
||||
component: EmojiManagementPage,
|
||||
})
|
||||
|
||||
// 资源管理路由 - 表达方式管理
|
||||
const expressionManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/expression',
|
||||
component: ExpressionManagementPage,
|
||||
})
|
||||
|
||||
// 资源管理路由 - 人物信息管理
|
||||
const personManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/person',
|
||||
component: PersonManagementPage,
|
||||
})
|
||||
|
||||
// 资源管理路由 - 黑话管理
|
||||
const jargonManagementRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/jargon',
|
||||
component: JargonManagementPage,
|
||||
})
|
||||
|
||||
// 资源管理路由 - 知识库图谱可视化
|
||||
const knowledgeGraphRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/knowledge-graph',
|
||||
component: KnowledgeGraphPage,
|
||||
})
|
||||
|
||||
// 资源管理路由 - 麦麦知识库管理
|
||||
const knowledgeBaseRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/resource/knowledge-base',
|
||||
component: KnowledgeBasePage,
|
||||
})
|
||||
|
||||
// 日志查看器路由
|
||||
const logsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/logs',
|
||||
component: LogViewerPage,
|
||||
})
|
||||
|
||||
// 计划器&恢复器监控路由
|
||||
const plannerMonitorRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/planner-monitor',
|
||||
component: PlannerMonitorPage,
|
||||
})
|
||||
|
||||
// 本地聊天室路由
|
||||
const chatRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/chat',
|
||||
component: ChatPage,
|
||||
})
|
||||
|
||||
// 插件市场路由
|
||||
const pluginsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/plugins',
|
||||
component: PluginsPage,
|
||||
})
|
||||
|
||||
// 插件详情路由
|
||||
const pluginDetailRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/plugin-detail',
|
||||
component: PluginDetailPage,
|
||||
})
|
||||
|
||||
// 模型分配预设市场路由
|
||||
const modelPresetsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/model-presets',
|
||||
component: ModelPresetsPage,
|
||||
})
|
||||
|
||||
// 插件配置路由
|
||||
const pluginConfigRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/plugin-config',
|
||||
component: PluginConfigPage,
|
||||
})
|
||||
|
||||
// 插件镜像源配置路由
|
||||
const pluginMirrorsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/plugin-mirrors',
|
||||
component: PluginMirrorsPage,
|
||||
})
|
||||
|
||||
// 设置页路由
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/settings',
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
// 配置模板市场路由
|
||||
const packMarketRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/pack-market',
|
||||
component: PackMarketPage,
|
||||
})
|
||||
|
||||
// 配置模板详情路由
|
||||
export const packDetailRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/config/pack-market/$packId',
|
||||
component: PackDetailPage,
|
||||
})
|
||||
|
||||
// 问卷调查路由 - WebUI 反馈
|
||||
const webuiFeedbackSurveyRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/survey/webui-feedback',
|
||||
component: WebUIFeedbackSurveyPage,
|
||||
})
|
||||
|
||||
// 问卷调查路由 - 麦麦体验反馈
|
||||
const maibotFeedbackSurveyRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/survey/maibot-feedback',
|
||||
component: MaiBotFeedbackSurveyPage,
|
||||
})
|
||||
|
||||
// 年度报告路由
|
||||
const annualReportRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute,
|
||||
path: '/annual-report',
|
||||
component: AnnualReportPage,
|
||||
})
|
||||
|
||||
// 404 路由
|
||||
const notFoundRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '*',
|
||||
component: NotFoundPage,
|
||||
})
|
||||
|
||||
// 路由树
|
||||
const routeTree = rootRoute.addChildren([
|
||||
authRoute,
|
||||
setupRoute,
|
||||
protectedRoute.addChildren([
|
||||
indexRoute,
|
||||
botConfigRoute,
|
||||
modelProviderConfigRoute,
|
||||
modelConfigRoute,
|
||||
adapterConfigRoute,
|
||||
emojiManagementRoute,
|
||||
expressionManagementRoute,
|
||||
jargonManagementRoute,
|
||||
personManagementRoute,
|
||||
knowledgeGraphRoute,
|
||||
knowledgeBaseRoute,
|
||||
pluginsRoute,
|
||||
pluginDetailRoute,
|
||||
modelPresetsRoute,
|
||||
pluginConfigRoute,
|
||||
pluginMirrorsRoute,
|
||||
logsRoute,
|
||||
plannerMonitorRoute,
|
||||
chatRoute,
|
||||
settingsRoute,
|
||||
packMarketRoute,
|
||||
packDetailRoute,
|
||||
webuiFeedbackSurveyRoute,
|
||||
maibotFeedbackSurveyRoute,
|
||||
annualReportRoute,
|
||||
]),
|
||||
notFoundRoute,
|
||||
])
|
||||
|
||||
// 创建路由器
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultNotFoundComponent: NotFoundPage,
|
||||
defaultErrorComponent: ({ error }) => <RouteErrorBoundary error={error} />,
|
||||
})
|
||||
|
||||
// 类型声明
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
61
dashboard/src/routes/404.tsx
Normal file
61
dashboard/src/routes/404.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Home, Search, ArrowLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function NotFoundPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-2xl text-center">
|
||||
{/* 404 大标题 */}
|
||||
<div className="relative mb-8">
|
||||
<h1 className="text-[150px] font-black leading-none text-primary/10 select-none sm:text-[200px]">
|
||||
404
|
||||
</h1>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Search className="h-20 w-20 text-primary/30 sm:h-24 sm:w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<h2 className="text-2xl font-bold text-foreground sm:text-3xl">
|
||||
页面未找到
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground sm:text-lg max-w-md mx-auto">
|
||||
抱歉,您访问的页面不存在或已被移除。请检查 URL 是否正确,或返回首页继续浏览。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => navigate({ to: '/' })}
|
||||
className="gap-2 w-full sm:w-auto"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => window.history.back()}
|
||||
className="gap-2 w-full sm:w-auto"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回上一页
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="mt-12 pt-8 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
如果您认为这是一个错误,请联系系统管理员
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
883
dashboard/src/routes/annual-report.tsx
Normal file
883
dashboard/src/routes/annual-report.tsx
Normal file
@@ -0,0 +1,883 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { getAnnualReport, type AnnualReportData } from '@/lib/annual-report-api'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { toPng } from 'html-to-image'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
Brain,
|
||||
Smile,
|
||||
Trophy,
|
||||
Calendar,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
Moon,
|
||||
Sun,
|
||||
AtSign,
|
||||
Heart,
|
||||
Image as ImageIcon,
|
||||
Bot,
|
||||
Download,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 颜色常量
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d']
|
||||
|
||||
// 动态比喻生成函数
|
||||
function getOnlineHoursMetaphor(hours: number): string {
|
||||
if (hours >= 8760) return "相当于全年无休,7x24小时在线!"
|
||||
if (hours >= 5000) return "相当于一位全职员工的年工作时长"
|
||||
if (hours >= 2000) return "相当于看完了 1000 部电影"
|
||||
if (hours >= 1000) return "相当于环球飞行 80 次"
|
||||
if (hours >= 500) return "相当于读完了 100 本书"
|
||||
if (hours >= 100) return "相当于马拉松跑了 25 次"
|
||||
return "虽然不多,但每一刻都很珍贵"
|
||||
}
|
||||
|
||||
function getMidnightMetaphor(count: number): string {
|
||||
if (count >= 1000) return "夜深人静时的知心好友"
|
||||
if (count >= 500) return "午夜场的常客"
|
||||
if (count >= 100) return "偶尔熬夜的小伙伴"
|
||||
if (count >= 50) return "深夜有时也会陪你聊聊"
|
||||
return "早睡早起,健康作息"
|
||||
}
|
||||
|
||||
function getTokenMetaphor(tokens: number): string {
|
||||
const millions = tokens / 1000000
|
||||
if (millions >= 100) return "思考量堪比一座图书馆"
|
||||
if (millions >= 50) return "相当于写了一部百科全书"
|
||||
if (millions >= 10) return "脑细胞估计消耗了不少"
|
||||
if (millions >= 1) return "也算是费了一番脑筋"
|
||||
return "轻轻松松,游刃有余"
|
||||
}
|
||||
|
||||
function getCostMetaphor(cost: number): string {
|
||||
if (cost >= 1000) return "这钱够吃一年的泡面了"
|
||||
if (cost >= 500) return "相当于买了一台游戏机"
|
||||
if (cost >= 100) return "够请大家喝几杯奶茶"
|
||||
if (cost >= 50) return "一顿火锅的钱"
|
||||
if (cost >= 10) return "几杯咖啡的价格"
|
||||
return "省钱小能手"
|
||||
}
|
||||
|
||||
function getSilenceMetaphor(rate: number): string {
|
||||
if (rate >= 80) return "沉默是金,惜字如金"
|
||||
if (rate >= 60) return "话不多但句句到位"
|
||||
if (rate >= 40) return "该说的时候才开口"
|
||||
if (rate >= 20) return "能聊的都聊了"
|
||||
return "话痨本痨,有问必答"
|
||||
}
|
||||
|
||||
function getImageMetaphor(count: number): string {
|
||||
if (count >= 10000) return "眼睛都快看花了"
|
||||
if (count >= 5000) return "堪比专业摄影师的阅片量"
|
||||
if (count >= 1000) return "看图小达人"
|
||||
if (count >= 500) return "图片鉴赏家"
|
||||
if (count >= 100) return "偶尔欣赏一下美图"
|
||||
return "图片?有空再看"
|
||||
}
|
||||
|
||||
function getRejectedMetaphor(count: number): string {
|
||||
if (count >= 500) return "在不断的纠正中成长"
|
||||
if (count >= 200) return "学习永无止境"
|
||||
if (count >= 100) return "虚心接受,积极改正"
|
||||
if (count >= 50) return "偶尔也会犯错"
|
||||
if (count >= 10) return "表现还算不错"
|
||||
return "完美表达,无需纠正"
|
||||
}
|
||||
|
||||
function getExpensiveThinkingMetaphor(cost: number): string {
|
||||
if (cost >= 1) return "这次思考的价值堪比一顿大餐!"
|
||||
if (cost >= 0.5) return "为了这个问题,我可是认真思考了!"
|
||||
if (cost >= 0.1) return "下了点功夫,值得的!"
|
||||
if (cost >= 0.01) return "花了点小钱,但很值得"
|
||||
return "小小思考,不足挂齿"
|
||||
}
|
||||
|
||||
function getFavoriteReplyMetaphor(count: number, botName: string): string {
|
||||
if (count >= 100) return "这句话简直是万能钥匙!"
|
||||
if (count >= 50) return "百试不爽的经典回复"
|
||||
if (count >= 20) return `${botName}的口头禅`
|
||||
if (count >= 10) return "常用语录之一"
|
||||
return "偶尔用用的小确幸"
|
||||
}
|
||||
|
||||
function getNightOwlMetaphor(isNightOwl: boolean, midnightCount: number): string {
|
||||
if (isNightOwl) {
|
||||
if (midnightCount >= 1000) return "深夜的守护者,黑暗中的光芒"
|
||||
if (midnightCount >= 500) return "月亮是我的好朋友"
|
||||
if (midnightCount >= 100) return "越夜越精神,夜晚才是主场"
|
||||
return "偶尔熬夜,享受宁静时光"
|
||||
} else {
|
||||
if (midnightCount <= 10) return "作息规律,健康生活的典范"
|
||||
if (midnightCount <= 50) return "早睡早起,偶尔也会熬个夜"
|
||||
return "虽然是早起鸟,但也会守候深夜"
|
||||
}
|
||||
}
|
||||
|
||||
function getBusiestDayMetaphor(count: number): string {
|
||||
if (count >= 1000) return "忙到飞起,键盘都要冒烟了"
|
||||
if (count >= 500) return "这天简直是话痨附体"
|
||||
if (count >= 200) return "社交达人上线"
|
||||
if (count >= 100) return "比平时活跃不少"
|
||||
if (count >= 50) return "小忙一下"
|
||||
return "还算轻松的一天"
|
||||
}
|
||||
|
||||
export function AnnualReportPage() {
|
||||
const [year] = useState(2025)
|
||||
const [data, setData] = useState<AnnualReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const reportRef = useRef<HTMLDivElement>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
const loadReport = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const result = await getAnnualReport(year)
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('获取年度报告失败'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [year])
|
||||
|
||||
// 导出为图片
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!reportRef.current || !data) return
|
||||
|
||||
setIsExporting(true)
|
||||
toast({
|
||||
title: '正在生成图片',
|
||||
description: '请稍候...',
|
||||
})
|
||||
|
||||
try {
|
||||
const element = reportRef.current
|
||||
|
||||
// 获取当前主题的背景色
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const backgroundColor = computedStyle.getPropertyValue('--background').trim()
|
||||
? `hsl(${computedStyle.getPropertyValue('--background').trim()})`
|
||||
: (document.documentElement.classList.contains('dark') ? '#0a0a0a' : '#ffffff')
|
||||
|
||||
// 保存原始样式
|
||||
const originalWidth = element.style.width
|
||||
const originalMaxWidth = element.style.maxWidth
|
||||
|
||||
// 临时设置固定宽度以去除左右空白
|
||||
element.style.width = '1024px'
|
||||
element.style.maxWidth = '1024px'
|
||||
|
||||
const dataUrl = await toPng(element, {
|
||||
quality: 1,
|
||||
pixelRatio: 2,
|
||||
backgroundColor,
|
||||
cacheBust: true,
|
||||
filter: (node) => {
|
||||
// 过滤掉导出按钮
|
||||
if (node instanceof HTMLElement && node.hasAttribute('data-export-btn')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复原始样式
|
||||
element.style.width = originalWidth
|
||||
element.style.maxWidth = originalMaxWidth
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
link.download = `${data.bot_name}_${data.year}_年度总结.png`
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
|
||||
toast({
|
||||
title: '导出成功',
|
||||
description: '年度报告已保存为图片',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('导出图片失败:', err)
|
||||
toast({
|
||||
title: '导出失败',
|
||||
description: '请重试',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}, [data, toast])
|
||||
|
||||
useEffect(() => {
|
||||
loadReport()
|
||||
}, [loadReport])
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center text-red-500">
|
||||
获取年度报告失败: {error.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-4rem)]">
|
||||
<div className="min-h-screen bg-gradient-to-b from-background to-muted/50 p-4 md:p-8 print:p-0" ref={reportRef}>
|
||||
<div className="mx-auto max-w-5xl space-y-8 print:space-y-4">
|
||||
{/* 头部 Hero */}
|
||||
<header className="relative overflow-hidden rounded-3xl bg-primary p-8 text-primary-foreground shadow-2xl print:rounded-none print:shadow-none">
|
||||
{/* 导出按钮 */}
|
||||
<div className="absolute right-4 top-4 z-20 print:hidden" data-export-btn>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="gap-2 bg-white/20 hover:bg-white/30 text-white border-white/30"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
导出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
保存图片
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col items-center text-center">
|
||||
<Bot className="mb-4 h-16 w-16 animate-bounce" />
|
||||
<h1 className="text-4xl font-bold tracking-tighter sm:text-6xl">
|
||||
{data.bot_name} {data.year} 年度总结
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg opacity-90">
|
||||
连接与成长 · Connection & Growth
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-2 text-sm opacity-75">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>生成时间: {data.generated_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute -right-20 -top-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="absolute -bottom-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
</header>
|
||||
|
||||
{/* 维度一:时光足迹 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Clock className="h-8 w-8" />
|
||||
<h2>时光足迹</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="年度在线时长"
|
||||
value={`${data.time_footprint.total_online_hours} 小时`}
|
||||
description={getOnlineHoursMetaphor(data.time_footprint.total_online_hours)}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="最忙碌的一天"
|
||||
value={data.time_footprint.busiest_day || 'N/A'}
|
||||
description={getBusiestDayMetaphor(data.time_footprint.busiest_day_count)}
|
||||
icon={<Calendar className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="深夜互动 (0-4点)"
|
||||
value={`${data.time_footprint.midnight_chat_count} 次`}
|
||||
description={getMidnightMetaphor(data.time_footprint.midnight_chat_count)}
|
||||
icon={<Moon className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="作息属性"
|
||||
value={data.time_footprint.is_night_owl ? '夜猫子' : '早起鸟'}
|
||||
description={getNightOwlMetaphor(data.time_footprint.is_night_owl, data.time_footprint.midnight_chat_count)}
|
||||
icon={data.time_footprint.is_night_owl ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>24小时活跃时钟</CardTitle>
|
||||
<CardDescription>{data.bot_name}在一天中各个时段的活跃程度</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.time_footprint.hourly_distribution.map((count: number, hour: number) => ({ hour: `${hour}点`, count }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="hour" />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
||||
cursor={{ fill: 'transparent' }}
|
||||
/>
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{data.time_footprint.first_message_time && (
|
||||
<Card className="bg-muted/30 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<p className="text-muted-foreground mb-2">2025年的故事开始于</p>
|
||||
<div className="text-xl font-bold text-primary mb-1">{data.time_footprint.first_message_time}</div>
|
||||
<p className="text-lg">
|
||||
<span className="font-semibold text-foreground">{data.time_footprint.first_message_user}</span> 说:
|
||||
<span className="italic text-muted-foreground">"{data.time_footprint.first_message_content}"</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 维度二:社交网络 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Users className="h-8 w-8" />
|
||||
<h2>社交网络</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard
|
||||
title="社交圈子"
|
||||
value={`${data.social_network.total_groups} 个群组`}
|
||||
description={`${data.bot_name}加入的群组总数`}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="被呼叫次数"
|
||||
value={`${data.social_network.at_count + data.social_network.mentioned_count} 次`}
|
||||
description="我的名字被大家频繁提起"
|
||||
icon={<AtSign className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="最长情陪伴"
|
||||
value={data.social_network.longest_companion_user || 'N/A'}
|
||||
description={`始终都在,已陪伴 ${data.social_network.longest_companion_days} 天`}
|
||||
icon={<Heart className="h-4 w-4 text-red-500" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>话痨群组 TOP5</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{data.social_network.top_groups.length > 0 ? (
|
||||
data.social_network.top_groups.map((group: { group_id: string; group_name: string; message_count: number; is_webui?: boolean }, index: number) => (
|
||||
<div key={group.group_id} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="font-medium truncate max-w-[120px]">{group.group_name}</span>
|
||||
{group.is_webui && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
|
||||
WebUI
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm shrink-0">{group.message_count} 条消息</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-4">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>年度最佳损友 TOP5</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{data.social_network.top_users.length > 0 ? (
|
||||
data.social_network.top_users.map((user: { user_id: string; user_nickname: string; message_count: number; is_webui?: boolean }, index: number) => (
|
||||
<div key={user.user_id} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={index === 0 ? "default" : "secondary"} className="h-6 w-6 rounded-full p-0 flex items-center justify-center shrink-0">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="font-medium truncate max-w-[120px]">{user.user_nickname}</span>
|
||||
{user.is_webui && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5 bg-blue-50 text-blue-600 border-blue-200">
|
||||
WebUI
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm shrink-0">{user.message_count} 次互动</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-4">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 维度三:最强大脑 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Brain className="h-8 w-8" />
|
||||
<h2>最强大脑</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="年度 Token 消耗"
|
||||
value={(data.brain_power.total_tokens / 1000000).toFixed(2) + ' M'}
|
||||
description={getTokenMetaphor(data.brain_power.total_tokens)}
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="年度总花费"
|
||||
value={`$${data.brain_power.total_cost.toFixed(2)}`}
|
||||
description={getCostMetaphor(data.brain_power.total_cost)}
|
||||
icon={<span className="font-bold">$</span>}
|
||||
/>
|
||||
<StatCard
|
||||
title="高冷指数"
|
||||
value={`${data.brain_power.silence_rate}%`}
|
||||
description={getSilenceMetaphor(data.brain_power.silence_rate)}
|
||||
icon={<Moon className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="最高兴趣值"
|
||||
value={data.brain_power.max_interest_value ?? 'N/A'}
|
||||
description={data.brain_power.max_interest_time ? `出现在 ${data.brain_power.max_interest_time}` : '暂无数据'}
|
||||
icon={<Heart className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模型偏好分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{data.brain_power.model_distribution.slice(0, 5).map((item: { model: string; count: number }, index: number) => {
|
||||
const maxCount = data.brain_power.model_distribution[0]?.count || 1
|
||||
const percentage = Math.round((item.count / maxCount) * 100)
|
||||
return (
|
||||
<div key={item.model} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
|
||||
<span className="text-muted-foreground">{item.count.toLocaleString()} 次</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: COLORS[index % COLORS.length]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 最喜欢的回复模型 TOP5 */}
|
||||
{data.brain_power.top_reply_models && data.brain_power.top_reply_models.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最喜欢的回复模型 TOP5</CardTitle>
|
||||
<CardDescription>{data.bot_name}用来回复消息的模型偏好</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{data.brain_power.top_reply_models.map((item: { model: string; count: number }, index: number) => {
|
||||
const maxCount = data.brain_power.top_reply_models[0]?.count || 1
|
||||
const percentage = Math.round((item.count / maxCount) * 100)
|
||||
return (
|
||||
<div key={item.model} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium truncate max-w-[200px]">{item.model}</span>
|
||||
<span className="text-muted-foreground">{item.count.toLocaleString()} 次</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: COLORS[index % COLORS.length]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 烧钱大户 - 只有有有效用户数据时才显示 */}
|
||||
{data.brain_power.top_token_consumers && data.brain_power.top_token_consumers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>烧钱大户 TOP3</CardTitle>
|
||||
<CardDescription>谁消耗了最多的 API 额度</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{data.brain_power.top_token_consumers.map((consumer: { user_id: string; cost: number; tokens: number }) => (
|
||||
<div key={consumer.user_id} className="space-y-2">
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>用户 {consumer.user_id}</span>
|
||||
<span>${consumer.cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${(consumer.cost / (data.brain_power.top_token_consumers[0]?.cost || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 最昂贵的思考 & 思考深度 */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/20 dark:to-orange-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">💰</span>
|
||||
最昂贵的一次思考
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<div className="text-4xl font-bold text-amber-600 dark:text-amber-400">
|
||||
${data.brain_power.most_expensive_cost.toFixed(4)}
|
||||
</div>
|
||||
{data.brain_power.most_expensive_time && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
发生在 {data.brain_power.most_expensive_time}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{getExpensiveThinkingMetaphor(data.brain_power.most_expensive_cost)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-950/20 dark:to-blue-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🧠</span>
|
||||
思考深度
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{data.brain_power.avg_reasoning_length?.toFixed(0) || 0}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">平均思考字数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{data.brain_power.max_reasoning_length?.toLocaleString() || 0}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">最长思考字数</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.brain_power.max_reasoning_time && (
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||
最深沉的思考发生在 {data.brain_power.max_reasoning_time}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 维度四:个性与表达 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Smile className="h-8 w-8" />
|
||||
<h2>个性与表达</h2>
|
||||
</div>
|
||||
|
||||
{/* 深夜回复 & 最喜欢的回复 */}
|
||||
{(data.expression_vibe.late_night_reply || data.expression_vibe.favorite_reply) && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{data.expression_vibe.late_night_reply && (
|
||||
<Card className="bg-gradient-to-br from-indigo-50 to-violet-50 dark:from-indigo-950/20 dark:to-violet-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🌙</span>
|
||||
深夜还在回复
|
||||
</CardTitle>
|
||||
<CardDescription>凌晨 {data.expression_vibe.late_night_reply.time},{data.bot_name}还在回复...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-lg italic text-muted-foreground">
|
||||
"{data.expression_vibe.late_night_reply.content}"
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
是有什么心事吗?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data.expression_vibe.favorite_reply && (
|
||||
<Card className="bg-gradient-to-br from-rose-50 to-pink-50 dark:from-rose-950/20 dark:to-pink-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">💬</span>
|
||||
最喜欢的回复
|
||||
</CardTitle>
|
||||
<CardDescription>使用了 {data.expression_vibe.favorite_reply.count} 次</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-lg font-medium text-primary">
|
||||
"{data.expression_vibe.favorite_reply.content}"
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{getFavoriteReplyMetaphor(data.expression_vibe.favorite_reply.count, data.bot_name)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* 使用最多的表情包 TOP3 */}
|
||||
<Card className="bg-gradient-to-br from-pink-50 to-purple-50 dark:from-pink-950/20 dark:to-purple-950/20">
|
||||
<CardHeader>
|
||||
<CardTitle>使用最多的表情包 TOP3</CardTitle>
|
||||
<CardDescription>年度最爱的表情包们</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.expression_vibe.top_emojis && data.expression_vibe.top_emojis.length > 0 ? (
|
||||
<div className="flex justify-center gap-4">
|
||||
{data.expression_vibe.top_emojis.slice(0, 3).map((emoji: { id: number; usage_count: number }, index: number) => (
|
||||
<div key={emoji.id} className="flex flex-col items-center">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`/api/webui/emoji/${emoji.id}/thumbnail?original=true`}
|
||||
alt={`TOP ${index + 1}`}
|
||||
className="h-24 w-24 rounded-lg object-cover shadow-md transition-transform hover:scale-105"
|
||||
/>
|
||||
<Badge
|
||||
className={cn(
|
||||
"absolute -top-2 -right-2",
|
||||
index === 0 ? "bg-yellow-500" : index === 1 ? "bg-gray-400" : "bg-amber-700"
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{emoji.usage_count} 次</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground">暂无数据</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>印象最深刻的表达风格</CardTitle>
|
||||
<CardDescription>{data.bot_name}最常使用的表达方式</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.expression_vibe.top_expressions.map((exp: { style: string; count: number }, index: number) => (
|
||||
<Badge
|
||||
key={exp.style}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-1 text-sm",
|
||||
index === 0 && "border-primary bg-primary/10 text-primary text-base px-4 py-2"
|
||||
)}
|
||||
>
|
||||
{exp.style} ({exp.count})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard
|
||||
title="图片鉴赏"
|
||||
value={`${data.expression_vibe.image_processed_count} 张`}
|
||||
description={getImageMetaphor(data.expression_vibe.image_processed_count)}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="成长的足迹"
|
||||
value={`${data.expression_vibe.rejected_expression_count} 次`}
|
||||
description={getRejectedMetaphor(data.expression_vibe.rejected_expression_count)}
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行动派 */}
|
||||
{data.expression_vibe.action_types.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">⚡</span>
|
||||
行动派
|
||||
</CardTitle>
|
||||
<CardDescription>除了聊天,我还帮大家做了这些事</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.expression_vibe.action_types.map((action: { action: string; count: number }) => (
|
||||
<div
|
||||
key={action.action}
|
||||
className="flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2"
|
||||
>
|
||||
<span className="font-medium text-primary">{action.action}</span>
|
||||
<Badge variant="secondary">{action.count} 次</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 维度五:趣味成就 */}
|
||||
<section className="space-y-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 text-2xl font-bold text-primary">
|
||||
<Trophy className="h-8 w-8" />
|
||||
<h2>趣味成就</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="col-span-1 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>新学到的"黑话"</CardTitle>
|
||||
<CardDescription>今年我学会了 {data.achievements.new_jargon_count} 个新词</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.achievements.sample_jargons.map((jargon: { content: string; meaning: string; count: number }) => (
|
||||
<div key={jargon.content} className="group relative rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="font-bold text-primary">{jargon.content}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2 max-w-[200px]">
|
||||
{jargon.meaning || '暂无解释'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex flex-col justify-center items-center bg-primary text-primary-foreground">
|
||||
<CardContent className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<MessageSquare className="h-12 w-12 mb-4 opacity-80" />
|
||||
<div className="text-4xl font-bold mb-2">{data.achievements.total_messages.toLocaleString()}</div>
|
||||
<div className="text-sm opacity-80">年度总消息数</div>
|
||||
<div className="mt-4 text-xs opacity-60">
|
||||
其中回复了 {data.achievements.total_replies.toLocaleString()} 次
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 底部 */}
|
||||
<footer className="mt-12 text-center text-muted-foreground">
|
||||
<p>MaiBot 2025 Annual Report</p>
|
||||
<p className="text-sm">Generated with ❤️ by MaiBot Team</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<div className="text-muted-foreground">{icon}</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="container mx-auto space-y-8 p-8">
|
||||
<Skeleton className="h-64 w-full rounded-3xl" />
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
342
dashboard/src/routes/auth.tsx
Normal file
342
dashboard/src/routes/auth.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Key, Lock, AlertCircle, Moon, Sun, HelpCircle, FileText, Terminal, Zap } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { WavesBackground } from '@/components/waves-background'
|
||||
import { useAnimation } from '@/hooks/use-animation'
|
||||
import { useTheme } from '@/components/use-theme'
|
||||
import { checkAuthStatus } from '@/lib/fetch-with-auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { APP_FULL_NAME } from '@/lib/version'
|
||||
|
||||
export function AuthPage() {
|
||||
const [token, setToken] = useState('')
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [checkingAuth, setCheckingAuth] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
const { enableWavesBackground, setEnableWavesBackground } = useAnimation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
// 如果已经认证,直接跳转到首页
|
||||
useEffect(() => {
|
||||
const verifyAuth = async () => {
|
||||
try {
|
||||
const isAuth = await checkAuthStatus()
|
||||
if (isAuth) {
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误,保持在登录页
|
||||
} finally {
|
||||
setCheckingAuth(false)
|
||||
}
|
||||
}
|
||||
verifyAuth()
|
||||
}, [navigate])
|
||||
|
||||
// 获取实际应用的主题(处理 system 情况)
|
||||
const getActualTheme = () => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
const actualTheme = getActualTheme()
|
||||
|
||||
// 主题切换(无动画)
|
||||
const toggleTheme = () => {
|
||||
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!token.trim()) {
|
||||
setError('请输入 Access Token')
|
||||
return
|
||||
}
|
||||
|
||||
setIsValidating(true)
|
||||
|
||||
console.log('开始验证 token...')
|
||||
|
||||
try {
|
||||
// 向后端发送请求验证 token(后端会设置 HttpOnly Cookie)
|
||||
const response = await fetch('/api/webui/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include', // 确保接收并存储 Cookie
|
||||
body: JSON.stringify({ token: token.trim() }),
|
||||
})
|
||||
|
||||
console.log('Token 验证响应状态:', response.status)
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Token 验证响应数据:', data)
|
||||
|
||||
if (response.ok && data.valid) {
|
||||
console.log('Token 验证成功,准备跳转...')
|
||||
console.log('is_first_setup:', data.is_first_setup)
|
||||
|
||||
// Token 验证成功,Cookie 已由后端设置
|
||||
// 等待一小段时间确保 Cookie 已设置
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 再次检查认证状态
|
||||
const authCheck = await checkAuthStatus()
|
||||
console.log('跳转前认证状态检查:', authCheck)
|
||||
|
||||
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
|
||||
if (data.is_first_setup) {
|
||||
console.log('跳转到首次配置页面')
|
||||
// 需要首次配置,跳转到配置向导
|
||||
navigate({ to: '/setup' })
|
||||
} else {
|
||||
console.log('跳转到首页')
|
||||
// 不需要配置或配置已完成,跳转到首页
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
} else {
|
||||
console.error('Token 验证失败:', data.message)
|
||||
setError(data.message || 'Token 验证失败,请检查后重试')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Token 验证错误:', err)
|
||||
setError('连接服务器失败,请检查网络连接')
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 正在检查认证状态时显示加载
|
||||
if (checkingAuth) {
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
||||
{enableWavesBackground && <WavesBackground />}
|
||||
<div className="text-muted-foreground">正在检查登录状态...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
|
||||
{/* 波浪背景 - 独立控制 */}
|
||||
{enableWavesBackground && <WavesBackground />}
|
||||
|
||||
{/* 认证卡片 - 磨砂玻璃效果 */}
|
||||
<Card className="relative z-10 w-full max-w-md shadow-2xl backdrop-blur-xl bg-card/80 border-border/50">
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-accent transition-colors z-10 text-foreground"
|
||||
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{actualTheme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" strokeWidth={2.5} fill="none" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<CardHeader className="space-y-4 text-center">
|
||||
{/* Logo/Icon */}
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Lock className="h-8 w-8 text-primary" strokeWidth={2} fill="none" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="text-2xl font-bold">欢迎使用 MaiBot</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
请输入您的 Access Token 以继续访问系统
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Token 输入框 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token" className="text-sm font-medium">
|
||||
Access Token
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" strokeWidth={2} fill="none" />
|
||||
<Input
|
||||
id="token"
|
||||
type="password"
|
||||
placeholder="请输入您的 Access Token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className={cn('pl-10', error && 'border-red-500 focus-visible:ring-red-500')}
|
||||
disabled={isValidating}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/50 dark:text-red-400">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<Button type="submit" className="w-full" disabled={isValidating}>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
验证中...
|
||||
</>
|
||||
) : (
|
||||
'验证并进入'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 帮助文本 */}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button className="w-full text-center text-sm text-primary hover:text-primary/80 transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
|
||||
<HelpCircle className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
我没有 Token,我该去哪里获得 Token?
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
|
||||
如何获取 Access Token
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Access Token 是访问 MaiBot WebUI 的唯一凭证,请按以下方式获取
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 方式一:查看控制台 */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Terminal className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<h4 className="font-semibold text-sm">方式一:查看启动日志</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
在 MaiBot 启动时,控制台会显示 WebUI Access Token。
|
||||
</p>
|
||||
<div className="rounded bg-background p-2 font-mono text-xs">
|
||||
<p className="text-muted-foreground">🔑 WebUI Access Token: abc123...</p>
|
||||
<p className="text-muted-foreground">💡 请使用此 Token 登录 WebUI</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 方式二:查看配置文件 */}
|
||||
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<h4 className="font-semibold text-sm">方式二:查看配置文件</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Token 保存在项目根目录的配置文件中:
|
||||
</p>
|
||||
<div className="rounded bg-background p-2 font-mono text-xs break-all">
|
||||
<code className="text-primary">data/webui.json</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
打开此文件,复制 <code className="px-1 py-0.5 bg-background rounded">access_token</code> 字段的值
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3">
|
||||
<div className="flex gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-300 space-y-1">
|
||||
<p className="font-semibold">安全提示</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 text-xs">
|
||||
<li>请妥善保管您的 Token,不要泄露给他人</li>
|
||||
<li>如需重置 Token,请在登录后前往系统设置</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 性能优化选项 */}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button className="w-full text-center text-sm text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
|
||||
<Zap className="h-4 w-4" strokeWidth={2} fill="none" />
|
||||
我觉得这个界面很卡怎么办?
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
|
||||
关闭背景动画
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
背景动画可能会在低性能设备上造成卡顿。关闭动画可以显著提升界面流畅度。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
关闭动画后,背景将变为纯色,但不影响任何功能的使用。您可以随时在系统设置中重新开启动画。
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => setEnableWavesBackground(false)}
|
||||
>
|
||||
关闭动画
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 页脚信息 */}
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center text-xs text-muted-foreground">
|
||||
<p>{APP_FULL_NAME}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user