Compare commits

..

7 Commits

Author SHA1 Message Date
手瓜一十雪
05043f8dfc fix: code 2024-12-03 21:41:53 +08:00
手瓜一十雪
dc2b45fa00 fix: 优化处理 2024-12-03 21:36:55 +08:00
手瓜一十雪
cc70fc766a fix 2024-12-03 21:14:18 +08:00
手瓜一十雪
d2e9db5571 fix: 临时的抽象方案 2024-12-03 20:55:24 +08:00
手瓜一十雪
8e01638a36 fix: error 2024-12-03 19:50:47 +08:00
手瓜一十雪
e8b8eae8a9 refactor: GroupAdminChange 2024-12-03 19:44:38 +08:00
手瓜一十雪
0f0275243b feat: 迁移事件解析原理 2024-12-03 19:28:51 +08:00
781 changed files with 22110 additions and 85741 deletions

View File

@@ -12,10 +12,10 @@ insert_final_newline = true
# Set default charset # Set default charset
charset = utf-8 charset = utf-8
# 4 space indentation # 2 space indentation
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}] [*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 4
[*.bat] [*.bat]
charset = latin1 charset = latin1

View File

@@ -1,2 +0,0 @@
VITE_BUILD_TYPE = DEBUG
VITE_BUILD_PLATFORM = Shell

View File

@@ -1,25 +0,0 @@
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载
NapCat.Shell.Windows.OneKey.zip (无头)
NapCat.Framework.Windows.OneKey.zip (有头)
启动后可自动化部署一键包,教程参考使用文档安装部分
## 警告
**注意QQ版本推荐使用 40768+ 版本 最低可以使用40768版本**
**默认WebUi密钥为随机密码 控制台查看**
**[9.9.22-40990 X64 Win](https://dldir1v6.qq.com/qqfile/qq/QQNT/2c9d3f6c/QQ9.9.22.40990_x64.exe)**
[LinuxX64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_amd64.deb)
[LinuxX64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_x86_64.rpm)
[LinuxArm64 DEB 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_arm64.deb)
[LinuxArm64 RPM 40990 ](https://dldir1.qq.com/qqfile/qq/QQNT/ec800879/linuxqq_3.2.20-40990_aarch64.rpm)
[MAC DMG 40990 ](https://dldir1v6.qq.com/qqfile/qq/QQNT/c6cb0f5d/QQ_v6.9.82.40990.dmg)
## 如果WinX64缺少运行库或者xxx.dll
[安装运行库](https://aka.ms/vs/17/release/vc_redist.x64.exe)

View File

@@ -136,51 +136,17 @@ jobs:
- name: Extract version from tag - name: Extract version from tag
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download Windows OneKey Packages - name: Clone Changes Log
run: | run: curl -o CHANGELOG.md https://fastly.jsdelivr.net/gh/NapNeko/NapCatQQ@main/docs/changelogs/CHANGELOG.v${{ env.VERSION }}.md
# Get latest release tag using jq for reliable JSON parsing
LATEST_RELEASE=$(curl -s https://api.github.com/repos/NapNeko/NapCatQQ/releases/latest | jq -r '.tag_name')
echo "Latest release: $LATEST_RELEASE"
# Download OneKey packages if they exist (non-zero exit code won't fail the step)
curl -f -L -o NapCat.Shell.Windows.OneKey.zip "https://github.com/NapNeko/NapCatQQ/releases/download/$LATEST_RELEASE/NapCat.Shell.Windows.OneKey.zip" || echo "Warning: NapCat.Shell.Windows.OneKey.zip not found in latest release"
curl -f -L -o NapCat.Framework.Windows.OneKey.zip "https://github.com/NapNeko/NapCatQQ/releases/download/$LATEST_RELEASE/NapCat.Framework.Windows.OneKey.zip" || echo "Warning: NapCat.Framework.Windows.OneKey.zip not found in latest release"
- name: Generate Release Notes
run: |
# Create header with version
echo "# V${{ env.VERSION }} Refactor" > RELEASE_NOTES.md
# Add fixed release information
cat .github/release-template/release-info.md >> RELEASE_NOTES.md
# Add changelog section
echo "" >> RELEASE_NOTES.md
chmod +x ./script/generate-changelog.sh
./script/generate-changelog.sh >> RELEASE_NOTES.md
- name: Create Release Draft and Upload Artifacts - name: Create Release Draft and Upload Artifacts
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
name: NapCat V${{ env.VERSION }} name: NapCat V${{ env.VERSION }}
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
body_path: RELEASE_NOTES.md body_path: CHANGELOG.md
files: | files: |
NapCat.Framework.zip NapCat.Framework.zip
NapCat.Shell.zip NapCat.Shell.zip
NapCat.Framework.Windows.Once.zip NapCat.Framework.Windows.Once.zip
NapCat.Shell.Windows.OneKey.zip
NapCat.Framework.Windows.OneKey.zip
draft: true draft: true
build-docker:
needs: release-napcat
runs-on: ubuntu-latest
steps:
- name: Dispatch Docker Build
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.NAPCAT_BUILD }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
-d '{"ref": "main"}'

5
.gitignore vendored
View File

@@ -1,12 +1,14 @@
# Develop # Develop
node_modules/ node_modules/
package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
out/ out/
dist/ dist/
/src/core.lib/common/ /src/core.lib/common/
devconfig/* /localdebug/
# Editor # Editor
.vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea/* .idea/*
@@ -14,4 +16,3 @@ devconfig/*
*.db *.db
checkVersion.sh checkVersion.sh
bun.lockb bun.lockb
tests/run/

10
.prettierrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 120,
"endOfLine": "auto"
}

115
.vscode/launch.json vendored
View File

@@ -1,115 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "dev:shell",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:shell"
]
},
{
"type": "node",
"request": "launch",
"name": "build:shell",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"build:shell"
]
},
{
"type": "node",
"request": "launch",
"name": "build:universal",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"build:universal"
]
},
{
"type": "node",
"request": "launch",
"name": "build:framework",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"build:framework"
]
},
{
"type": "node",
"request": "launch",
"name": "build:webui",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"build:webui"
]
},
{
"type": "node",
"request": "launch",
"name": "dev:universal",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:universal"
]
},
{
"type": "node",
"request": "launch",
"name": "dev:framework",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:framework"
]
},
{
"type": "node",
"request": "launch",
"name": "dev:webui",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:webui"
]
},
{
"type": "node",
"request": "launch",
"name": "lint",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"lint"
]
},
{
"type": "node",
"request": "launch",
"name": "depend",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"depend"
]
},
{
"type": "node",
"request": "launch",
"name": "dev:depend",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev:depend"
]
}
]
}

37
.vscode/settings.json vendored
View File

@@ -1,37 +0,0 @@
{
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
".env.universal": ".env.*",
"vite.config.ts": "vite*.ts",
"README.md": "CODE_OF_CONDUCT.md, RELEASES.md, CONTRIBUTING.md, CHANGELOG.md, SECURITY.md",
"tsconfig.json": "tsconfig.*.json, env.d.ts",
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
},
"css.customData": [
".vscode/tailwindcss.json"
],
"editor.detectIndentation": false,
"editor.tabSize": 2,
"editor.formatOnSave": true,
"editor.formatOnType": false,
"editor.formatOnPaste": true,
"editor.formatOnSaveMode": "file",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"files.autoSave": "onFocusChange",
"javascript.preferences.quoteStyle": "single",
"typescript.preferences.quoteStyle": "single",
"javascript.format.semicolons": "insert",
"typescript.format.semicolons": "insert",
"javascript.format.insertSpaceBeforeFunctionParenthesis": true,
"typescript.format.insertSpaceBeforeFunctionParenthesis": true,
"typescript.format.insertSpaceAfterConstructor": true,
"javascript.format.insertSpaceAfterConstructor": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "minimal",
"javascript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.disableAutomaticTypeAcquisition": true,
}

View File

@@ -1,55 +0,0 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

View File

@@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
nanaeonn@outlook.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@@ -1,89 +1,53 @@
<img src="https://napneko.github.io/assets/newnewlogo.png" width = "305" height = "411" alt="NapCat" align=right />
<div align="center"> <div align="center">
# NapCat ![Logo](https://socialify.git.ci/NapNeko/NapCatQQ/image?font=Jost&logo=https%3A%2F%2Fnapneko.github.io%2Fassets%2Flogo.png&name=1&owner=1&pattern=Diagonal%20Stripes&stargazers=1&theme=Auto)
_Modern protocol-side framework implemented based on NTQQ._
> 云起兮风生,心向远方兮路未曾至.
</div> </div>
--- ---
## 欢迎回家
NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
## New Feature ## 特性介绍
- [x] **安装简单**:就算是笨蛋也能使用
- [x] **性能友好**:就算是低内存也能使用
- [x] **接口丰富**:就算是没有也能使用
- [x] **稳定好用**:就算是被捉也能使用
在 v4.8.115+ 版本开始 ## 使用框架
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
2. NapCatQQ 推荐 message_id/user_id/group_id 均使用字符串类型
- [1] 解决 Docker/跨设备/大文件 的多媒体上下传问题
- [2] 采用字符串可以解决扩展到int64的问题同时也可以解决部分语言如JavaScript对大整数支持不佳的问题增加极少成本。
## Welcome
- NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
## Feature
- **Easy to Use**
- 作为初学者能够轻松使用.
- **Quick and Efficient**
- 在低内存操作系统长时运行.
- **Rich API Interface**
- 完整实现了大部分标准接口.
- **Stable and Reliable**
- 持续稳定的开发与维护.
## Quick Start
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本 可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
**首次使用**请务必查看如下文档看使用教程 **首次使用**请务必查看如下文档看使用教程
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。 ### 文档地址
## Link [Cloudflare.Worker](https://doc.napneko.icu/)
| Docs | [![Github.IO](https://img.shields.io/badge/docs%20on-Github.IO-orange)](https://napneko.github.io/) | [![Cloudflare.Worker](https://img.shields.io/badge/docs%20on-Cloudflare.Worker-black)](https://doc.napneko.icu/) | [![Cloudflare.HKServer](https://img.shields.io/badge/docs%20on-Cloudflare.HKServer-informational)](https://napcat.napneko.icu/) | [Cloudflare.HKServer](https://napcat.napneko.icu/)
|:-:|:-:|:-:|:-:|
| Docs | [![Cloudflare.Pages](https://img.shields.io/badge/docs%20on-Cloudflare.Pages-blue)](https://napneko.pages.dev/) | [![Server.Other](https://img.shields.io/badge/docs%20on-Server.Other-green)](https://napcat.cyou/) | [![NapCat.Wiki](https://img.shields.io/badge/docs%20on-NapCat.Wiki-red)](https://www.napcat.wiki) | [Github.IO](https://napneko.github.io/)
|:-:|:-:|:-:|:-:|
| QQ Group | [![QQ Group#4](https://img.shields.io/badge/QQ%20Group%234-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#3](https://img.shields.io/badge/QQ%20Group%233-Join-blue)](https://qm.qq.com/q/8zJMLjqy2Y) | [![QQ Group#2](https://img.shields.io/badge/QQ%20Group%232-Join-blue)](https://qm.qq.com/q/CMmPbGw0jA) | [![QQ Group#1](https://img.shields.io/badge/QQ%20Group%231-Join-blue)](https://qm.qq.com/q/I6LU87a0Yq) | [Cloudflare.Pages](https://napneko.pages.dev/)
|:-:|:-:|:-:|:-:|:-:|
| Telegram | [![Telegram](https://img.shields.io/badge/Telegram-napcatqq-blue)](https://t.me/napcatqq) | [Server.Other](https://napcat.cyou/)
|:-:|:-:|
| DeepWiki | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/NapNeko/NapCatQQ) |
|:-:|:-:|
> 请不要在其余社区提及本项目(包括其余协议端/相关应用端项目)引发争论如有建议到达官方交流群讨论或PR。 ## 回家旅途
[QQ Group](https://qm.qq.com/q/I6LU87a0Yq)
## Thanks ## 感谢他们
感谢 [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权 感谢 Tencent Tdesign / Vue3 强力驱动 NapCat.WebUi
- [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下 不过最最重要的 还是需要感谢屏幕前的你哦~
- [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
- [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
- 不过最最重要的 还是需要感谢屏幕前的你哦~
--- ---
## License ## 特殊感谢
[LLOneBot](https://github.com/LLOneBot/LLOneBot) 相关的开发曾参与本项目
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点: ## 开源附加
1. 第三方库代码或修改部分遵循其原始开源许可. 任何使用本仓库代码的地方,都应当严格遵守[本仓库开源许可](./LICENSE)。**此外,禁止任何项目未经仓库主作者授权二次分发或基于 NapCat 代码开发。**
2. 本项目获取部分项目授权而不受部分约束
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
**本仓库仅用于提高易用性,实现消息推送类功能,此外,禁止任何项目未经仓库主作者授权基于 NapCat 代码开发。使用请遵守当地法律法规,由此造成的问题由使用者和提供违规使用教程者负责。**

View File

@@ -1,11 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| > 4.0 | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
you should open an issue

View File

@@ -1,52 +1,70 @@
import neostandard from 'neostandard'; import typescriptEslint from "@typescript-eslint/eslint-plugin";
import _import from "eslint-plugin-import";
import { fixupPluginRules } from "@eslint/compat";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
/** 尾随逗号 */ const filename = fileURLToPath(import.meta.url);
const commaDangle = val => { const dirname = path.dirname(filename);
if (val?.rules?.['@stylistic/comma-dangle']?.[0] === 'warn') { const compat = new FlatCompat({
const rule = val?.rules?.['@stylistic/comma-dangle']?.[1]; baseDirectory: dirname,
Object.keys(rule).forEach(key => { recommendedConfig: js.configs.recommended,
rule[key] = 'always-multiline'; allConfig: js.configs.all
}); });
val.rules['@stylistic/comma-dangle'][1] = rule;
}
/** 三元表达式 */ export default [{
if (val?.rules?.['@stylistic/indent']) { ignores: ["src/core/proto/"],
val.rules['@stylistic/indent'][2] = { }, ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), {
...val.rules?.['@stylistic/indent']?.[2], plugins: {
flatTernaryExpressions: true, "@typescript-eslint": typescriptEslint,
offsetTernaryExpressions: false, import: fixupPluginRules(_import),
}; },
}
/** 支持下划线 - 禁用 camelcase 规则 */ languageOptions: {
if (val?.rules?.camelcase) { globals: {
val.rules.camelcase = 'off'; ...globals.browser,
} ...globals.node,
},
/** 未使用的变量强制报错 */ parser: tsParser,
if (val?.rules?.['@typescript-eslint/no-unused-vars']) { ecmaVersion: "latest",
val.rules['@typescript-eslint/no-unused-vars'] = ['error', { sourceType: "module",
argsIgnorePattern: '^_', },
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}];
}
return val; settings: {
}; "import/parsers": {
"@typescript-eslint/parser": [".ts"],
},
/** 忽略的文件 */ "import/resolver": {
const ignores = [ typescript: {
'node_modules', alwaysTryTypes: true,
'**/dist/**', },
'launcher', },
]; },
const options = neostandard({ rules: {
ts: true, indent: ["error", 4],
ignores, semi: ["error", "always"],
semi: true, // 强制使用分号 "no-unused-vars": "off",
}).map(commaDangle); "no-async-promise-executor": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-var-requires": "off",
"object-curly-spacing": ["error", "always"],
},
}, {
files: ["**/.eslintrc.{js,cjs}"],
export default options; languageOptions: {
globals: {
...globals.node,
},
ecmaVersion: 5,
sourceType: "commonjs",
},
}];

Binary file not shown.

BIN
external/logo.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read :loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b" set RetString=%%b
goto :napcat_boot goto :napcat_boot
) )
@@ -16,10 +16,10 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa" set "pathWithoutUninstall=%%~dpa"
) )
set "QQPath=%pathWithoutUninstall%QQ.exe" SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" ( if not exist "%QQpath%" (
echo provided QQ path is invalid echo provided QQ path is invalid: %QQpath%
pause pause
exit /b exit /b
) )

View File

@@ -7,7 +7,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read :loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b" set RetString=%%b
goto :napcat_boot goto :napcat_boot
) )
@@ -16,10 +16,10 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa" set "pathWithoutUninstall=%%~dpa"
) )
set "QQPath=%pathWithoutUninstall%QQ.exe" SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQpath%" ( if not exist "%QQpath%" (
echo provided QQ path is invalid echo provided QQ path is invalid: %QQpath%
pause pause
exit /b exit /b
) )

View File

@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read :loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b" set RetString=%%b
goto :napcat_boot goto :napcat_boot
) )
@@ -25,10 +25,10 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa" set "pathWithoutUninstall=%%~dpa"
) )
set "QQPath=%pathWithoutUninstall%QQ.exe" SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQPath%" ( if not exist "%QQpath%" (
echo provided QQ path is invalid echo provided QQ path is invalid: %QQpath%
pause pause
exit /b exit /b
) )

View File

@@ -16,7 +16,7 @@ set NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe
set NAPCAT_MAIN_PATH=%cd%\napcat.mjs set NAPCAT_MAIN_PATH=%cd%\napcat.mjs
:loop_read :loop_read
for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do (
set "RetString=%%~b" set RetString=%%b
goto :napcat_boot goto :napcat_boot
) )
@@ -25,10 +25,10 @@ for %%a in ("%RetString%") do (
set "pathWithoutUninstall=%%~dpa" set "pathWithoutUninstall=%%~dpa"
) )
set "QQPath=%pathWithoutUninstall%QQ.exe" SET QQPath=%pathWithoutUninstall%QQ.exe
if not exist "%QQPath%" ( if not exist "%QQpath%" (
echo provided QQ path is invalid echo provided QQ path is invalid: %QQpath%
pause pause
exit /b exit /b
) )

View File

@@ -1,9 +1,10 @@
{ {
"name": "qq-chat", "name": "qq-chat",
"verHash": "2c9d3f6c", "version": "9.9.16-29927",
"version": "9.9.22-40990", "verHash": "3e273e30",
"linuxVersion": "3.2.20-40990", "linuxVersion": "3.2.13-29927",
"linuxVerHash": "ec800879", "linuxVerHash": "833d113c",
"type": "module",
"private": true, "private": true,
"description": "QQ", "description": "QQ",
"productName": "QQ", "productName": "QQ",
@@ -17,9 +18,9 @@
"qd": "externals/devtools/cli/index.js" "qd": "externals/devtools/cli/index.js"
}, },
"main": "./loadNapCat.js", "main": "./loadNapCat.js",
"buildVersion": "40990", "buildVersion": "29927",
"isPureShell": true, "isPureShell": true,
"isByteCodeShell": true, "isByteCodeShell": true,
"platform": "win32", "platform": "win32",
"eleArch": "x64" "eleArch": "x64"
} }

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

After

Width:  |  Height:  |  Size: 335 KiB

View File

@@ -4,12 +4,16 @@
"name": "NapCatQQ", "name": "NapCatQQ",
"slug": "NapCat.Framework", "slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现", "description": "高性能的 OneBot 11 协议实现",
"version": "4.9.26", "version": "4.2.12",
"icon": "./logo.png", "icon": "./logo.png",
"authors": [ "authors": [
{ {
"name": "NapNeko", "name": "MliKiowa",
"link": "https://github.com/NapNeko" "link": "https://github.com/MliKiowa"
},
{
"name": "Young",
"link": "https://github.com/Wesley-Young"
} }
], ],
"repository": { "repository": {

View File

@@ -1 +0,0 @@
VITE_DEBUG_BACKEND_URL="http://127.0.0.1:6099"

View File

@@ -22,11 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# NPM LOCK files
package-lock.json
yarn.lock
pnpm-lock.yaml
dist.zip

View File

@@ -1 +0,0 @@
public-hoist-pattern[]=*@heroui/*

3
napcat.webui/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 bietiaop
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,32 +1,5 @@
# NapCat WebUI # Vue 3 + TypeScript + Vite
## 功能 This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
- WebUI登录 Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
- QQ登录
- 网络配置
- OneBot/WebUI配置
- 日志查看(实时日志、历史日志)
- HTTP调试
- WS调试
- 在线音乐播放器,支持网易云音乐歌单(大屏在页面右下角,小屏在页面下方)
如果你有更多功能需求,欢迎在 issue 中提出。
# License
[MIT](LICENSE)
# Related Projects
- [NapCat](https://github.com/NapNeko/NapCatQQ/)
- [Karin](https://github.com/KarinJS/Karin/)
# Thanks to
- [Vercel](https://vercel.com/)
- [React](https://react.dev/)
- [HeroUI](https://nextui.org/)
- and more open-source projects
感谢群友“维拉”提供的在线音乐API。

View File

@@ -1,2 +1,52 @@
import eslintConfig from '../eslint.config.mjs'; import globals from 'globals';
export default eslintConfig; import ts from 'typescript-eslint';
import vue from 'eslint-plugin-vue';
import prettier from 'eslint-plugin-prettier/recommended';
export default [
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
...ts.configs.recommended,
{
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-var-requires': 'warn',
},
},
...vue.configs['flat/base'],
{
files: ['*.vue', '**/*.vue'],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
{
rules: {
indent: ['error', 4],
semi: ['error', 'always'],
'no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-var-requires': 'warn',
'object-curly-spacing': ['error', 'always'],
'vue/v-for-delimiter-style': ['error', 'in'],
'vue/require-name-property': 'warn',
'vue/prefer-true-attribute-shorthand': 'warn',
'prefer-arrow-callback': 'warn',
},
},
prettier,
{
rules: {
'prettier/prettier': 'warn',
},
},
];

View File

@@ -1,23 +1,13 @@
<!doctype html> <!doctype html>
<html lang="zh"> <html lang="en">
<head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="./logo_webui.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>NapCat WebUI</title>
<title>NapCat WebUI</title> </head>
<meta key="title" content="NapCat WebUI" property="og:title" /> <body>
<meta content="NapCat WebUI基于React+tailwind+NextUI" property="og:description" /> <div id="app"></div>
<meta content="NapCat WebUI基于React+tailwind+NextUI" name="description" /> <script type="module" src="./src/main.ts"></script>
<meta key="viewport" </body>
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" </html>
name="viewport" />
<link href="/favicon.ico" rel="icon" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,124 +1,34 @@
{ {
"name": "napcat-webui", "name": "napcat.webui",
"private": true, "private": true,
"version": "0.0.6", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host=0.0.0.0", "webui:lint": "eslint --fix src/**/*.{js,ts,vue}",
"build": "tsc && vite build", "webui:dev": "vite",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix", "webui:build": "vite build",
"preview": "vite preview" "webui:preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "eslint-plugin-prettier": "^5.2.1",
"@dnd-kit/sortable": "^10.0.0", "qrcode": "^1.5.4",
"@dnd-kit/utilities": "^3.2.2", "tdesign-icons-vue-next": "^0.3.3",
"@heroui/accordion": "^2.2.8", "tdesign-vue-next": "^1.10.3",
"@heroui/avatar": "2.2.7", "vue": "^3.5.13",
"@heroui/breadcrumbs": "2.2.7", "vue-router": "^4.4.5"
"@heroui/button": "2.2.10",
"@heroui/card": "2.2.10",
"@heroui/checkbox": "2.3.9",
"@heroui/chip": "2.2.7",
"@heroui/code": "2.2.7",
"@heroui/dropdown": "2.3.10",
"@heroui/form": "2.1.9",
"@heroui/image": "2.2.6",
"@heroui/input": "2.4.10",
"@heroui/kbd": "2.2.7",
"@heroui/link": "2.2.8",
"@heroui/listbox": "2.3.10",
"@heroui/modal": "2.2.8",
"@heroui/navbar": "2.2.9",
"@heroui/pagination": "^2.2.9",
"@heroui/popover": "2.3.10",
"@heroui/select": "2.4.10",
"@heroui/skeleton": "^2.2.6",
"@heroui/slider": "2.4.8",
"@heroui/snippet": "2.2.11",
"@heroui/spinner": "2.2.7",
"@heroui/switch": "2.2.9",
"@heroui/system": "2.4.7",
"@heroui/table": "^2.2.9",
"@heroui/tabs": "2.2.8",
"@heroui/theme": "2.4.6",
"@heroui/tooltip": "2.2.8",
"@monaco-editor/loader": "^1.4.0",
"@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1",
"@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"ahooks": "^3.8.4",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"echarts": "^5.5.1",
"event-source-polyfill": "^1.0.31",
"framer-motion": "^12.0.6",
"monaco-editor": "^0.52.2",
"motion": "^12.0.6",
"path-browserify": "^1.0.1",
"qface": "^1.4.1",
"qrcode.react": "^4.2.0",
"quill": "^2.0.3",
"react": "^19.0.0",
"react-color": "^2.19.3",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.3",
"react-photo-view": "^1.2.7",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "^7.1.4",
"react-use-websocket": "^4.11.1",
"react-window": "^1.8.11",
"remark-gfm": "^4.0.0",
"tailwind-variants": "^0.3.0",
"tailwindcss": "^3.4.17",
"zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@react-types/shared": "^3.26.0", "@eslint/eslintrc": "^3.1.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@eslint/js": "^9.14.0",
"@types/crypto-js": "^4.2.2", "@types/qrcode": "^1.5.5",
"@types/event-source-polyfill": "^1.0.5", "@vitejs/plugin-legacy": "^5.4.3",
"@types/fabric": "^5.3.9", "@vitejs/plugin-vue": "^5.1.4",
"@types/node": "^22.12.0", "eslint-config-prettier": "^9.1.0",
"@types/path-browserify": "^1.0.3", "eslint-plugin-vue": "^9.31.0",
"@types/react": "^19.0.8", "globals": "^15.12.0",
"@types/react-dom": "^19.0.3", "terser": "^5.36.0",
"@types/react-window": "^1.8.8", "typescript": "~5.6.2",
"@vitejs/plugin-react": "^4.3.4", "vite": "^5.4.10",
"autoprefixer": "^10.4.20", "vue-tsc": "^2.1.8"
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.3",
"eslint-plugin-unused-imports": "^4.1.4",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"vite": "^6.0.5",
"vite-plugin-static-copy": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4"
},
"overrides": {
"ahooks": {
"react": "$react",
"react-dom": "$react-dom"
},
"react-window": {
"react": "$react",
"react-dom": "$react-dom"
}
} }
} }

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View File

@@ -1,5 +0,0 @@
{
"rewrites": [
{ "source": "/(.*)", "destination": "/" }
]
}

View 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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,90 +0,0 @@
import { Suspense, lazy, useEffect } from 'react';
import { Provider } from 'react-redux';
import { Route, Routes, useNavigate } from 'react-router-dom';
import PageBackground from '@/components/page_background';
import PageLoading from '@/components/page_loading';
import Toaster from '@/components/toaster';
import DialogProvider from '@/contexts/dialog';
import AudioProvider from '@/contexts/songs';
import useAuth from '@/hooks/auth';
import store from '@/store';
const WebLoginPage = lazy(() => import('@/pages/web_login'));
const IndexPage = lazy(() => import('@/pages/index'));
const QQLoginPage = lazy(() => import('@/pages/qq_login'));
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'));
const AboutPage = lazy(() => import('@/pages/dashboard/about'));
const ConfigPage = lazy(() => import('@/pages/dashboard/config'));
const DebugPage = lazy(() => import('@/pages/dashboard/debug'));
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'));
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'));
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'));
const LogsPage = lazy(() => import('@/pages/dashboard/logs'));
const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
function App () {
return (
<DialogProvider>
<Provider store={store}>
<PageBackground />
<Toaster />
<AudioProvider>
<Suspense fallback={<PageLoading />}>
<AuthChecker>
<AppRoutes />
</AuthChecker>
</Suspense>
</AudioProvider>
</Provider>
</DialogProvider>
);
}
function AuthChecker ({ children }: { children: React.ReactNode }) {
const { isAuth } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!isAuth) {
const search = new URLSearchParams(window.location.search);
const token = search.get('token');
let url = '/web_login';
if (token) {
url += `?token=${token}`;
}
navigate(url, { replace: true });
}
}, [isAuth, navigate]);
return <>{children}</>;
}
function AppRoutes () {
return (
<Routes>
<Route path='/' element={<IndexPage />}>
<Route index element={<DashboardIndexPage />} />
<Route path='network' element={<NetworkPage />} />
<Route path='config' element={<ConfigPage />} />
<Route path='logs' element={<LogsPage />} />
<Route path='debug' element={<DebugPage />}>
<Route path='ws' element={<WSDebug />} />
<Route path='http' element={<HttpDebug />} />
</Route>
<Route path='file_manager' element={<FileManagerPage />} />
<Route path='terminal' element={<TerminalPage />} />
<Route path='about' element={<AboutPage />} />
</Route>
<Route path='/qq_login' element={<QQLoginPage />} />
<Route path='/web_login' element={<WebLoginPage />} />
</Routes>
);
}
export default App;

112
napcat.webui/src/App.vue Normal file
View File

@@ -0,0 +1,112 @@
<template>
<div id="app" theme-mode="dark">
<router-view />
</div>
<div v-if="show">
<t-sticky-tool shape="round" placement="right-bottom" :offset="[-50, 10]" @click="changeTheme">
<t-sticky-item label="浅色" popup="切换浅色模式">
<template #icon><sunny-icon /></template>
</t-sticky-item>
<t-sticky-item label="深色" popup="切换深色模式">
<template #icon><mode-dark-icon /></template>
</t-sticky-item>
<t-sticky-item label="自动" popup="跟随系统">
<template #icon><control-platform-icon /></template>
</t-sticky-item>
</t-sticky-tool>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue';
import { ControlPlatformIcon, ModeDarkIcon, SunnyIcon } from 'tdesign-icons-vue-next';
const smallScreen = window.matchMedia('(max-width: 768px)');
interface Item {
label: string;
popup: string;
}
interface Context {
item: Item;
}
enum ThemeMode {
Dark = 'dark',
Light = 'light',
Auto = 'auto',
}
const themeLabelMap: Record<string, ThemeMode> = {
"浅色": ThemeMode.Light,
"深色": ThemeMode.Dark,
"自动": ThemeMode.Auto,
};
const show = ref<boolean>(true);
const createSetThemeAttributeFunction = () => {
let mediaQueryForAutoTheme: MediaQueryList | null = null;
return (mode: ThemeMode | null) => {
const element = document.documentElement;
if (mode === ThemeMode.Dark) {
element.setAttribute('theme-mode', ThemeMode.Dark);
} else if (mode === ThemeMode.Light) {
element.removeAttribute('theme-mode');
} else if (mode === ThemeMode.Auto) {
mediaQueryForAutoTheme = window.matchMedia('(prefers-color-scheme: dark)');
const handleMediaChange = (e: MediaQueryListEvent) => {
if (e.matches) {
element.setAttribute('theme-mode', ThemeMode.Dark);
} else {
element.removeAttribute('theme-mode');
}
};
mediaQueryForAutoTheme.addEventListener('change', handleMediaChange);
const event = new Event('change');
Object.defineProperty(event, 'matches', {
value: mediaQueryForAutoTheme.matches,
writable: false,
});
mediaQueryForAutoTheme.dispatchEvent(event);
onBeforeUnmount(() => {
if (mediaQueryForAutoTheme) {
mediaQueryForAutoTheme.removeEventListener('change', handleMediaChange);
}
});
}
};
};
const setThemeAttribute = createSetThemeAttributeFunction();
const getStoredTheme = (): ThemeMode | null => {
return localStorage.getItem('theme') as ThemeMode | null;
};
const initTheme = () => {
const storedTheme = getStoredTheme();
if (storedTheme === null) {
setThemeAttribute(ThemeMode.Auto);
} else {
setThemeAttribute(storedTheme);
}
};
const changeTheme = (context: Context) => {
const themeLabel = themeLabelMap[context.item.label] as ThemeMode;
console.log(themeLabel);
setThemeAttribute(themeLabel);
localStorage.setItem('theme', themeLabel);
};
const haddingFbars = () => {
show.value = !smallScreen.matches;
if (smallScreen.matches) {
localStorage.setItem('theme', 'auto');
}
};
onMounted(() => {
initTheme();
haddingFbars();
window.addEventListener('resize', haddingFbars);
});
onUnmounted(() => {
window.removeEventListener('resize', haddingFbars);
});
</script>
<style scoped></style>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View 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="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,205 @@
import { OneBotConfig } from '../../../src/onebot/config/config';
export class QQLoginManager {
private retCredential: string;
private readonly apiPrefix: string;
//调试时http://127.0.0.1:6099/api 打包时 ../api
constructor(retCredential: string, apiPrefix: string = '../api') {
this.retCredential = retCredential;
this.apiPrefix = apiPrefix;
}
// TODO:
public async GetOB11Config(): Promise<OneBotConfig> {
try {
const ConfigResponse = await fetch(`${this.apiPrefix}/OB11Config/GetConfig`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (ConfigResponse.status == 200) {
const ConfigResponseJson = await ConfigResponse.json();
if (ConfigResponseJson.code == 0) {
return ConfigResponseJson?.data as OneBotConfig;
}
}
} catch (error) {
console.error('Error getting OB11 config:', error);
}
return {} as OneBotConfig;
}
public async SetOB11Config(config: OneBotConfig): Promise<boolean> {
try {
const ConfigResponse = await fetch(`${this.apiPrefix}/OB11Config/SetConfig`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
body: JSON.stringify({ config: JSON.stringify(config) }),
});
if (ConfigResponse.status == 200) {
const ConfigResponseJson = await ConfigResponse.json();
if (ConfigResponseJson.code == 0) {
return true;
}
}
} catch (error) {
console.error('Error setting OB11 config:', error);
}
return false;
}
public async checkQQLoginStatus(): Promise<boolean> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/CheckLoginStatus`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return QQLoginResponseJson.data.isLogin;
}
}
} catch (error) {
console.error('Error checking QQ login status:', error);
}
return false;
}
public async checkQQLoginStatusWithQrcode(): Promise<{ qrcodeurl: string; isLogin: string } | undefined> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/CheckLoginStatus`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return QQLoginResponseJson.data;
}
}
} catch (error) {
console.error('Error checking QQ login status:', error);
}
return undefined;
}
public async checkWebUiLogined(): Promise<boolean> {
try {
const LoginResponse = await fetch(`${this.apiPrefix}/auth/check`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (LoginResponse.status == 200) {
const LoginResponseJson = await LoginResponse.json();
if (LoginResponseJson.code == 0) {
return true;
}
}
} catch (error) {
console.error('Error checking web UI login status:', error);
}
return false;
}
public async loginWithToken(token: string): Promise<string | null> {
try {
const loginResponse = await fetch(`${this.apiPrefix}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: token }),
});
const loginResponseJson = await loginResponse.json();
const retCode = loginResponseJson.code;
if (retCode === 0) {
this.retCredential = loginResponseJson.data.Credential;
return this.retCredential;
}
} catch (error) {
console.error('Error logging in with token:', error);
}
return null;
}
public async getQQLoginQrcode(): Promise<string> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/GetQQLoginQrcode`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return QQLoginResponseJson.data.qrcode || '';
}
}
} catch (error) {
console.error('Error getting QQ login QR code:', error);
}
return '';
}
public async getQQQuickLoginList(): Promise<string[]> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/GetQuickLoginList`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return QQLoginResponseJson.data || [];
}
}
} catch (error) {
console.error('Error getting QQ quick login list:', error);
}
return [];
}
public async setQuickLogin(uin: string): Promise<{ result: boolean; errMsg: string }> {
try {
const QQLoginResponse = await fetch(`${this.apiPrefix}/QQLogin/SetQuickLogin`, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + this.retCredential,
'Content-Type': 'application/json',
},
body: JSON.stringify({ uin: uin }),
});
if (QQLoginResponse.status == 200) {
const QQLoginResponseJson = await QQLoginResponse.json();
if (QQLoginResponseJson.code == 0) {
return { result: true, errMsg: '' };
} else {
return { result: false, errMsg: QQLoginResponseJson.message };
}
}
} catch (error) {
console.error('Error setting quick login:', error);
}
return { result: false, errMsg: '接口异常' };
}
}

View File

@@ -1,36 +0,0 @@
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import React from 'react';
import { ColorResult, SketchPicker } from 'react-color';
// 假定 heroui 提供的 Popover组件
interface ColorPickerProps {
color: string
onChange: (color: ColorResult) => void
}
const ColorPicker: React.FC<ColorPickerProps> = ({ color, onChange }) => {
const handleChange = (colorResult: ColorResult) => {
onChange(colorResult);
};
return (
<Popover triggerScaleOnOpen={false}>
<PopoverTrigger>
<div
className='w-36 h-8 rounded-md cursor-pointer border border-content4'
style={{ background: color }}
/>
</PopoverTrigger>
<PopoverContent>
<SketchPicker
color={color}
onChange={handleChange}
className='!bg-transparent !shadow-none'
/>
</PopoverContent>
</Popover>
);
};
export default ColorPicker;

View File

@@ -0,0 +1,58 @@
<template>
<t-layout class="dashboard-container">
<div ref="menuRef">
<SidebarMenu :menu-items="menuItems" class="sidebar-menu" />
</div>
<t-layout>
<router-view />
</t-layout>
</t-layout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import SidebarMenu from './webui/Nav.vue';
import emitter from '@/ts/event-bus';
interface MenuItem {
value: string;
icon: string;
label: string;
route: string;
}
const menuItems = ref<MenuItem[]>([
{ value: 'item1', icon: 'dashboard', label: '基础信息', route: '/dashboard/basic-info' },
{ value: 'item3', icon: 'wifi-1', label: '网络配置', route: '/dashboard/network-config' },
{ value: 'item4', icon: 'setting', label: '其余配置', route: '/dashboard/other-config' },
{ value: 'item5', icon: 'system-log', label: '日志查看', route: '/dashboard/log-view' },
{ value: 'item6', icon: 'info-circle', label: '关于我们', route: '/dashboard/about-us' },
]);
const menuRef = ref<HTMLDivElement | null>(null);
emitter.on('sendMenu', (event) => {
emitter.emit('sendWidth', menuRef.value?.offsetWidth);
localStorage.setItem('menuWidth', menuRef.value?.offsetWidth?.toString() || '0');
});
onMounted(() => {
localStorage.setItem('menuWidth', menuRef.value?.offsetWidth?.toString() || '0');
});
</script>
<style scoped>
.dashboard-container {
display: flex;
flex-direction: row;
height: 100vh;
width: 100%;
}
.sidebar-menu {
position: relative;
z-index: 2;
}
@media (max-width: 768px) {
.content {
padding: 10px;
}
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<t-card class="layout">
<div class="login-container">
<h2 class="sotheby-font">QQ Login</h2>
<div class="login-methods">
<t-tooltip content="快速登录">
<t-button
id="quick-login"
class="login-method"
:class="{ active: loginMethod === 'quick' }"
@click="loginMethod = 'quick'"
>Quick Login</t-button
>
</t-tooltip>
<t-tooltip content="二维码登录">
<t-button
id="qrcode-login"
class="login-method"
:class="{ active: loginMethod === 'qrcode' }"
@click="loginMethod = 'qrcode'"
>QR Code</t-button
>
</t-tooltip>
</div>
<div v-show="loginMethod === 'quick'" id="quick-login-dropdown" class="login-form">
<t-select
id="quick-login-select"
v-model="selectedAccount"
placeholder="Select Account"
@change="selectAccount"
>
<t-option v-for="account in quickLoginList" :key="account" :value="account">{{ account }}</t-option>
</t-select>
</div>
<div v-show="loginMethod === 'qrcode'" id="qrcode" class="qrcode">
<canvas ref="qrcodeCanvas"></canvas>
</div>
</div>
<t-footer class="footer">Power By NapCat.WebUi</t-footer>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import * as QRCode from 'qrcode';
import { useRouter } from 'vue-router';
import { MessagePlugin } from 'tdesign-vue-next';
import { QQLoginManager } from '@/backend/shell';
const router = useRouter();
const loginMethod = ref<'quick' | 'qrcode'>('quick');
const quickLoginList = ref<string[]>([]);
const selectedAccount = ref<string>('');
const qrcodeCanvas = ref<HTMLCanvasElement | null>(null);
const qqLoginManager = new QQLoginManager(localStorage.getItem('auth') || '');
let heartBeatTimer: number | null = null;
let qrcodeUrl: string = '';
const selectAccount = async (accountName: string): Promise<void> => {
const { result, errMsg } = await qqLoginManager.setQuickLogin(accountName);
if (result) {
if (heartBeatTimer) {
clearInterval(heartBeatTimer);
}
await MessagePlugin.success('登录成功即将跳转');
await router.push({ path: '/dashboard/basic-info' });
} else {
await MessagePlugin.error('登录失败,' + errMsg);
}
};
const generateQrCode = (data: string, canvas: HTMLCanvasElement | null): void => {
if (!canvas) {
console.error('Canvas element not found');
return;
}
QRCode.toCanvas(canvas, data, function (error: Error | null | undefined) {
if (error) {
console.error('Error generating QR Code:', error);
} else {
console.log('QR Code generated!');
}
});
};
const HeartBeat = async (): Promise<void> => {
const isLogined = await qqLoginManager.checkQQLoginStatusWithQrcode();
if (isLogined?.isLogin) {
if (heartBeatTimer) {
clearInterval(heartBeatTimer);
}
// //判断是否已经调转
// if (router.currentRoute.value.path !== '/dashboard/basic-info') {
// return;
// }
await MessagePlugin.success('登录成功即将跳转');
await router.push({ path: '/dashboard/basic-info' });
} else if (isLogined?.qrcodeurl && qrcodeUrl !== isLogined.qrcodeurl) {
qrcodeUrl = isLogined.qrcodeurl;
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
}
};
const InitPages = async (): Promise<void> => {
quickLoginList.value = await qqLoginManager.getQQQuickLoginList();
qrcodeUrl = await qqLoginManager.getQQLoginQrcode();
generateQrCode(qrcodeUrl, qrcodeCanvas.value);
heartBeatTimer = window.setInterval(HeartBeat, 3000);
};
onMounted(() => {
InitPages();
});
</script>
<style scoped>
.layout {
height: 100vh;
}
.login-container {
padding: 20px;
border-radius: 5px;
max-width: 400px;
min-width: 300px;
position: relative;
margin: 50px auto;
}
@media (max-width: 600px) {
.login-container {
width: 90%;
min-width: unset;
}
}
.login-methods {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.login-method {
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
}
.login-method.active {
background-color: #e6f0ff;
color: #007bff;
}
.login-form,
.qrcode {
display: flex;
flex-direction: column;
gap: 15px;
}
.qrcode {
display: flex;
justify-content: center;
align-items: center;
gap: 15px;
text-align: center;
}
.sotheby-font {
font-family: Sotheby, Helvetica, monospace;
font-size: 3.125rem;
line-height: 1.2;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.footer {
text-align: center;
margin: 0;
font-size: 0.875rem;
color: #888;
position: fixed;
bottom: 20px;
left: 0;
right: 0;
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<t-card class="layout">
<div class="login-container">
<h2 class="sotheby-font">WebUi Login</h2>
<t-form ref="form" :data="formData" colon :label-width="0" @submit="onSubmit">
<t-form-item name="password">
<t-input v-model="formData.token" type="password" clearable placeholder="请输入Token">
<template #prefix-icon>
<lock-on-icon />
</template>
</t-input>
</t-form-item>
<t-form-item>
<t-button theme="primary" type="submit" block>登录</t-button>
</t-form-item>
</t-form>
</div>
<t-footer class="footer">Power By NapCat.WebUi</t-footer>
</t-card>
</template>
<script setup lang="ts">
import '../css/style.css';
import '../css/font.css';
import { reactive, onMounted } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import { LockOnIcon } from 'tdesign-icons-vue-next';
import { useRouter } from 'vue-router';
import { QQLoginManager } from '@/backend/shell';
const router = useRouter();
interface FormData {
token: string;
}
const formData: FormData = reactive({
token: '',
});
const handleLoginSuccess = async (credential: string) => {
localStorage.setItem('auth', credential);
await checkLoginStatus();
};
const handleLoginFailure = (message: string) => {
MessagePlugin.error(message);
};
const checkLoginStatus = async () => {
const storedCredential = localStorage.getItem('auth');
if (!storedCredential) {
return;
}
const loginManager = new QQLoginManager(storedCredential);
const isWenUiLoggedIn = await loginManager.checkWebUiLogined();
console.log('isWenUiLoggedIn', isWenUiLoggedIn);
if (!isWenUiLoggedIn) {
return;
}
const isQQLoggedIn = await loginManager.checkQQLoginStatus();
if (isQQLoggedIn) {
await router.push({ path: '/dashboard/basic-info' });
} else {
await router.push({ path: '/qqlogin' });
}
};
const loginWithToken = async (token: string) => {
const loginManager = new QQLoginManager('');
const credential = await loginManager.loginWithToken(token);
if (credential) {
await handleLoginSuccess(credential);
} else {
handleLoginFailure('登录失败请检查Token');
}
};
onMounted(() => {
const url = new URL(window.location.href);
const token = url.searchParams.get('token');
if (token) {
loginWithToken(token);
}
checkLoginStatus();
});
const onSubmit = async ({ validateResult }: { validateResult: boolean }) => {
if (validateResult) {
await loginWithToken(formData.token);
} else {
handleLoginFailure('请填写Token');
}
};
</script>
<style scoped>
.layout {
height: 100vh;
}
.login-container {
padding: 20px;
border-radius: 5px;
max-width: 400px;
min-width: 300px;
position: relative;
margin: 50px auto;
}
@media (max-width: 600px) {
.login-container {
width: 90%;
min-width: unset;
}
}
.tdesign-demo-block-column {
display: flex;
flex-direction: column;
row-gap: 16px;
}
.tdesign-demo-block-column-large {
display: flex;
flex-direction: column;
row-gap: 32px;
}
.tdesign-demo-block-row {
display: flex;
column-gap: 16px;
align-items: center;
}
.sotheby-font {
font-family: Sotheby, Helvetica, monospace;
font-size: 3.125rem;
line-height: 1.2;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.footer {
text-align: center;
margin: 0;
font-size: 0.875rem;
color: #888;
position: fixed;
bottom: 20px;
left: 0;
right: 0;
}
</style>

View File

@@ -1,425 +0,0 @@
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Image } from '@heroui/image';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Slider } from '@heroui/slider';
import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import {
BiSolidSkipNextCircle,
BiSolidSkipPreviousCircle,
} from 'react-icons/bi';
import {
FaPause,
FaPlay,
FaRegHandPointRight,
FaRepeat,
FaShuffle,
} from 'react-icons/fa6';
import { TbRepeatOnce } from 'react-icons/tb';
import { useMediaQuery } from 'react-responsive';
import { PlayMode } from '@/const/enum';
import key from '@/const/key';
import { VolumeHighIcon, VolumeLowIcon } from './icons';
export interface AudioPlayerProps
extends React.AudioHTMLAttributes<HTMLAudioElement> {
src: string
title?: string
artist?: string
cover?: string
pressNext?: () => void
pressPrevious?: () => void
onPlayEnd?: () => void
onChangeMode?: (mode: PlayMode) => void
mode?: PlayMode
}
export default function AudioPlayer (props: AudioPlayerProps) {
const {
src,
pressNext,
pressPrevious,
cover = 'https://nextui.org/images/album-cover.png',
title = '未知',
artist = '未知',
onTimeUpdate,
onLoadedData,
onPlay,
onPause,
onPlayEnd,
onChangeMode,
autoPlay,
mode = PlayMode.Loop,
...rest
} = props;
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100);
const [isCollapsed, setIsCollapsed] = useLocalStorage(
key.isCollapsedMusicPlayer,
false
);
const audioRef = useRef<HTMLAudioElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const startY = useRef(0);
const startX = useRef(0);
const [translateY, setTranslateY] = useState(0);
const [translateX, setTranslateX] = useState(0);
const isSmallScreen = useMediaQuery({ maxWidth: 767 });
const isMediumUp = useMediaQuery({ minWidth: 768 });
const shouldAdd = useRef(false);
const currentProgress = (currentTime / duration) * 100;
const [storageAutoPlay, setStorageAutoPlay] = useLocalStorage(
key.autoPlay,
true
);
const handleTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement;
setCurrentTime(audio.currentTime);
onTimeUpdate?.(event);
};
const handleLoadedData = (event: React.SyntheticEvent<HTMLAudioElement>) => {
const audio = event.target as HTMLAudioElement;
setDuration(audio.duration);
onLoadedData?.(event);
};
const handlePlay = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(true);
setStorageAutoPlay(true);
onPlay?.(e);
};
const handlePause = (e: React.SyntheticEvent<HTMLAudioElement>) => {
setIsPlaying(false);
onPause?.(e);
};
const changeMode = () => {
const modes = [PlayMode.Loop, PlayMode.Random, PlayMode.Single];
const currentIndex = modes.findIndex((_mode) => _mode === mode);
const nextIndex = currentIndex + 1;
const nextMode = modes[nextIndex] || modes[0];
onChangeMode?.(nextMode);
};
const volumeChange = (value: number) => {
setVolume(value);
};
useEffect(() => {
const audio = audioRef.current;
if (audio) {
audio.volume = volume / 100;
}
}, [volume]);
const handleTouchStart = (e: React.TouchEvent) => {
startY.current = e.touches[0].clientY;
startX.current = e.touches[0].clientX;
};
const handleTouchMove = (e: React.TouchEvent) => {
const deltaY = e.touches[0].clientY - startY.current;
const deltaX = e.touches[0].clientX - startX.current;
const container = cardRef.current;
const header = cardRef.current?.querySelector('[data-header]');
const headerHeight = header?.clientHeight || 20;
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
const _shouldAdd = isCollapsed && deltaY < 0;
if (isSmallScreen) {
shouldAdd.current = _shouldAdd;
setTranslateY(_shouldAdd ? deltaY + addHeight : deltaY);
} else {
setTranslateX(deltaX);
}
};
const handleTouchEnd = () => {
if (isSmallScreen) {
const container = cardRef.current;
const header = cardRef.current?.querySelector('[data-header]');
const headerHeight = header?.clientHeight || 20;
const addHeight = (container?.clientHeight || headerHeight) - headerHeight;
const _translateY = translateY - (shouldAdd.current ? addHeight : 0);
if (_translateY > 100) {
setIsCollapsed(true);
} else if (_translateY < -100) {
setIsCollapsed(false);
}
setTranslateY(0);
} else {
if (translateX > 100) {
setIsCollapsed(true);
} else if (translateX < -100) {
setIsCollapsed(false);
}
setTranslateX(0);
}
};
const dragTranslate = isSmallScreen
? translateY
? `translateY(${translateY}px)`
: ''
: translateX
? `translateX(${translateX}px)`
: '';
const collapsedTranslate = isCollapsed
? isSmallScreen
? 'translateY(90%)'
: 'translateX(96%)'
: '';
const translateStyle = dragTranslate || collapsedTranslate;
if (!src) return null;
return (
<div
className={clsx(
'fixed right-0 bottom-0 z-[52] w-full md:w-96',
!translateX && !translateY && 'transition-transform',
isCollapsed && 'md:hover:!translate-x-80'
)}
style={{
transform: translateStyle,
}}
>
<audio
src={src}
onLoadedData={handleLoadedData}
onTimeUpdate={handleTimeUpdate}
onPlay={handlePlay}
onPause={handlePause}
onEnded={onPlayEnd}
autoPlay={autoPlay ?? storageAutoPlay}
{...rest}
controls={false}
hidden
ref={audioRef}
/>
<Card
ref={cardRef}
className={clsx(
'border-none bg-background/60 dark:bg-default-300/50 w-full max-w-full transform transition-transform backdrop-blur-md duration-300 overflow-visible',
isSmallScreen ? 'rounded-t-3xl' : 'md:rounded-l-xl'
)}
classNames={{
body: 'p-0',
}}
shadow='sm'
radius='none'
>
{isMediumUp && (
<Button
isIconOnly
className={clsx(
'absolute data-[hover]:bg-foreground/10 text-lg z-50',
isCollapsed
? 'top-0 left-0 w-full h-full rounded-xl bg-opacity-0 hover:bg-opacity-30'
: 'top-3 -left-8 rounded-l-full bg-opacity-50 backdrop-blur-md'
)}
variant='solid'
color='primary'
size='sm'
onPress={() => setIsCollapsed(!isCollapsed)}
>
<FaRegHandPointRight />
</Button>
)}
{isSmallScreen && (
<CardHeader
data-header
className='flex-row justify-center pt-4'
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<div className='w-24 h-2 rounded-full bg-content2-foreground shadow-sm' />
</CardHeader>
)}
<CardBody>
<div className='grid grid-cols-6 md:grid-cols-12 gap-6 md:gap-4 items-center justify-center overflow-hidden p-6 md:p-2 m-0'>
<div className='relative col-span-6 md:col-span-4 flex justify-center'>
<Image
alt='Album cover'
className='object-cover'
classNames={{
wrapper: 'w-36 aspect-square md:w-24 flex',
img: 'block w-full h-full',
}}
shadow='md'
src={cover}
width='100%'
/>
</div>
<div className='flex flex-col col-span-6 md:col-span-8'>
<div className='flex flex-col gap-0'>
<h1 className='font-medium truncate'>{title}</h1>
<p className='text-xs text-foreground/80 truncate'>{artist}</p>
</div>
<div className='flex flex-col'>
<Slider
aria-label='Music progress'
classNames={{
track: 'bg-default-500/30 border-none',
thumb: 'w-2 h-2 after:w-1.5 after:h-1.5',
filler: 'rounded-full',
}}
color='foreground'
value={currentProgress || 0}
defaultValue={0}
size='sm'
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value;
const audio = audioRef.current;
if (audio) {
audio.currentTime = (value / 100) * duration;
}
}}
/>
<div className='flex justify-between h-3'>
<p className='text-xs'>
{Math.floor(currentTime / 60)}:
{Math.floor(currentTime % 60)
.toString()
.padStart(2, '0')}
</p>
<p className='text-xs text-foreground/50'>
{Math.floor(duration / 60)}:
{Math.floor(duration % 60)
.toString()
.padStart(2, '0')}
</p>
</div>
</div>
<div className='flex w-full items-center justify-center'>
<Tooltip
content={
mode === PlayMode.Loop
? '列表循环'
: mode === PlayMode.Random
? '随机播放'
: '单曲循环'
}
>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-lg md:text-medium'
radius='full'
variant='light'
size='md'
onPress={changeMode}
>
{mode === PlayMode.Loop && (
<FaRepeat className='text-foreground/80' />
)}
{mode === PlayMode.Random && (
<FaShuffle className='text-foreground/80' />
)}
{mode === PlayMode.Single && (
<TbRepeatOnce className='text-foreground/80 text-xl' />
)}
</Button>
</Tooltip>
<Tooltip content='上一首'>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
radius='full'
variant='light'
size='md'
onPress={pressPrevious}
>
<BiSolidSkipPreviousCircle />
</Button>
</Tooltip>
<Tooltip content={isPlaying ? '暂停' : '播放'}>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-3xl md:text-3xl'
radius='full'
variant='light'
size='lg'
onPress={() => {
if (isPlaying) {
audioRef.current?.pause();
setStorageAutoPlay(false);
} else {
audioRef.current?.play();
}
}}
>
{isPlaying ? <FaPause /> : <FaPlay className='ml-1' />}
</Button>
</Tooltip>
<Tooltip content='下一首'>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-2xl md:text-xl'
radius='full'
variant='light'
size='md'
onPress={pressNext}
>
<BiSolidSkipNextCircle />
</Button>
</Tooltip>
<Popover
placement='top'
classNames={{
content: 'bg-opacity-30 backdrop-blur-md',
}}
>
<PopoverTrigger>
<Button
isIconOnly
className='data-[hover]:bg-foreground/10 text-xl md:text-xl'
radius='full'
variant='light'
size='md'
>
<VolumeHighIcon />
</Button>
</PopoverTrigger>
<PopoverContent>
<Slider
orientation='vertical'
showTooltip
aria-label='Volume'
className='h-40'
color='primary'
defaultValue={volume}
onChange={(value) => {
value = Array.isArray(value) ? value[0] : value;
volumeChange(value);
}}
startContent={<VolumeHighIcon className='text-2xl' />}
size='sm'
endContent={<VolumeLowIcon className='text-2xl' />}
/>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</CardBody>
</Card>
</div>
);
}

View File

@@ -1,208 +0,0 @@
import { Button } from '@heroui/button';
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
} from '@heroui/dropdown';
import { Tooltip } from '@heroui/tooltip';
import { FaRegCircleQuestion } from 'react-icons/fa6';
import { IoAddCircleOutline } from 'react-icons/io5';
import {
HTTPClientIcon,
HTTPServerIcon,
PCIcon,
PlusIcon,
WebsocketIcon,
} from '../icons';
export interface AddButtonProps {
onOpen: (key: keyof OneBotConfig['network']) => void
}
const AddButton: React.FC<AddButtonProps> = (props) => {
const { onOpen } = props;
return (
<Dropdown
classNames={{
content: 'bg-opacity-30 backdrop-blur-md',
}}
placement='right'
>
<DropdownTrigger>
<Button
color='primary'
startContent={<IoAddCircleOutline className='text-2xl' />}
>
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label='Create Network Config'
color='primary'
variant='flat'
onAction={(key) => {
onOpen(key as keyof OneBotConfig['network']);
}}
>
<DropdownItem
key='title'
isReadOnly
className='cursor-default hover:!bg-transparent'
textValue='title'
>
<div className='flex items-center gap-2 justify-center'>
<div className='w-5 h-5 -ml-3'>
<PlusIcon />
</div>
<div className='text-primary-400'></div>
</div>
</DropdownItem>
<DropdownItem
key='httpServers'
textValue='httpServers'
startContent={
<div className='w-6 h-6'>
<HTTPServerIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
HTTP服务器
<Tooltip
content='「由NapCat建立」一个HTTP服务器你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址你或者你的框架应该连接到这个地址。'
showArrow
className='max-w-64'
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
>
<FaRegCircleQuestion />
</Button>
</Tooltip>
</div>
</DropdownItem>
<DropdownItem
key='httpSseServers'
textValue='httpSseServers'
startContent={
<div className='w-6 h-6'>
<HTTPServerIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
HTTP SSE服务器
<Tooltip
content='「由NapCat建立」一个HTTP SSE服务器你可以「使用框架连接」此服务器或者「自己构造请求发送」至此服务器。NapCat会根据你配置的IP和端口等建立一个地址你或者你的框架应该连接到这个地址。'
showArrow
className='max-w-64'
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
>
<FaRegCircleQuestion />
</Button>
</Tooltip>
</div>
</DropdownItem>
<DropdownItem
key='httpClients'
textValue='httpClients'
startContent={
<div className='w-6 h-6'>
<HTTPClientIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
HTTP客户端
<Tooltip
content='「由框架或者你自己建立」的一个用于「接收」NapCat向你发送请求的客户端通常框架会提供一个HTTP地址。这个地址是你使用的框架提供的NapCat会主动连接它。'
showArrow
className='max-w-64'
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
>
<FaRegCircleQuestion />
</Button>
</Tooltip>
</div>
</DropdownItem>
<DropdownItem
key='websocketServers'
textValue='websocketServers'
startContent={
<div className='w-6 h-6'>
<WebsocketIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
Websocket服务器
<Tooltip
content='「由NapCat建立」一个WebSocket服务器你的框架应该连接到此服务器。NapCat会根据你配置的IP和端口等建立一个WebSocket地址你或者你的框架应该连接到这个地址。'
showArrow
className='max-w-64'
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
>
<FaRegCircleQuestion />
</Button>
</Tooltip>
</div>
</DropdownItem>
<DropdownItem
key='websocketClients'
textValue='websocketClients'
startContent={
<div className='w-6 h-6'>
<PCIcon />
</div>
}
>
<div className='flex gap-1 items-center'>
Websocket客户端
<Tooltip
content='「由框架或者你自己建立」的WebSocket通常框架会「提供」一个ws地址NapCat会主动连接它。'
showArrow
className='max-w-64'
>
<Button
isIconOnly
radius='full'
size='sm'
variant='light'
className='w-4 h-4 min-w-0'
>
<FaRegCircleQuestion />
</Button>
</Tooltip>
</div>
</DropdownItem>
</DropdownMenu>
</Dropdown>
);
};
export default AddButton;

View File

@@ -1,59 +0,0 @@
import { Button } from '@heroui/button';
import clsx from 'clsx';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
export interface SaveButtonsProps {
onSubmit: () => void
reset: () => void
refresh?: () => void
isSubmitting: boolean
className?: string
}
const SaveButtons: React.FC<SaveButtonsProps> = ({
onSubmit,
reset,
isSubmitting,
refresh,
className,
}) => (
<div
className={clsx(
'max-w-full mx-3 w-96 flex flex-col justify-center gap-3',
className
)}
>
<div className='flex items-center justify-center gap-2 mt-5'>
<Button
color='default'
onPress={() => {
reset();
toast.success('重置成功');
}}
>
</Button>
<Button
color='primary'
isLoading={isSubmitting}
onPress={() => onSubmit()}
>
</Button>
{refresh && (
<Button
isIconOnly
color='secondary'
radius='full'
variant='flat'
onPress={() => refresh()}
>
<IoMdRefresh size={24} />
</Button>
)}
</div>
</div>
);
export default SaveButtons;

View File

@@ -1,254 +0,0 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Tooltip } from '@heroui/tooltip';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { FaMicrophone } from 'react-icons/fa6';
import { IoMic } from 'react-icons/io5';
import { MdEdit, MdUpload } from 'react-icons/md';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
import { isURI } from '@/utils/url';
import type { OB11Segment } from '@/types/onebot';
const AudioInsert = () => {
const [audioUrl, setAudioUrl] = useState<string>('');
const audioInputRef = useRef<HTMLInputElement>(null);
const showStructuredMessage = useShowStructuredMessage();
const showAudioSegment = (file: string) => {
const messages: OB11Segment[] = [
{
type: 'record',
data: {
file,
},
},
];
showStructuredMessage(messages);
};
const [isRecording, setIsRecording] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const [audioPreview, setAudioPreview] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(false);
const streamRef = useRef<MediaStream | null>(null);
const [recordingTime, setRecordingTime] = useState(0);
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (isRecording) {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
streamRef.current = stream;
const recorder = new MediaRecorder(stream);
mediaRecorderRef.current = recorder;
recorder.start();
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
if (audioChunksRef.current.length > 0) {
const audioBlob = new Blob(audioChunksRef.current, {
type: 'audio/wav',
});
const reader = new FileReader();
reader.readAsDataURL(audioBlob);
reader.onloadend = () => {
const base64Audio = reader.result as string;
setAudioPreview(base64Audio);
setShowPreview(true);
};
audioChunksRef.current = [];
}
stream.getTracks().forEach((track) => track.stop());
};
});
recordingIntervalRef.current = setInterval(() => {
setRecordingTime((prevTime) => prevTime + 1);
}, 1000);
} else {
mediaRecorderRef.current?.stop();
if (recordingIntervalRef.current) {
clearInterval(recordingIntervalRef.current);
recordingIntervalRef.current = null;
}
}
}, [isRecording]);
const startRecording = () => {
setAudioPreview(null);
setShowPreview(false);
setRecordingTime(0);
setIsRecording(true);
};
const stopRecording = () => {
setIsRecording(false);
};
const handleShowPreview = () => {
if (audioPreview) {
showAudioSegment(audioPreview);
}
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = time % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
return (
<>
<Popover>
<Tooltip content='发送音频'>
<div className='max-w-fit'>
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<IoMic className='text-xl' />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Tooltip content='上传音频'>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
onPress={() => {
audioInputRef?.current?.click();
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content='输入音频地址'>
<div className='max-w-fit'>
<PopoverTrigger tooltip='输入音频地址'>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-1 p-2'>
<Input
value={audioUrl}
onChange={(e) => setAudioUrl(e.target.value)}
placeholder='请输入音频地址'
/>
<Button
color='primary'
variant='flat'
isIconOnly
radius='full'
onPress={() => {
if (!isURI(audioUrl)) {
toast.error('请输入正确的音频地址');
return;
}
showAudioSegment(audioUrl);
setAudioUrl('');
}}
>
<FaMicrophone />
</Button>
</PopoverContent>
</Popover>
<Popover>
<Tooltip content='录制音频'>
<div className='max-w-fit'>
<PopoverTrigger>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
>
<IoMic />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-col gap-2 p-4'>
<div className='flex gap-2'>
<Button
color={isRecording ? 'primary' : 'primary'}
variant='flat'
onPress={isRecording ? stopRecording : startRecording}
>
{isRecording ? '停止录制' : '开始录制'}
</Button>
{showPreview && audioPreview && (
<Button
color='primary'
variant='flat'
onPress={handleShowPreview}
>
</Button>
)}
</div>
{(isRecording || audioPreview) && (
<div className='flex gap-1 items-center'>
<span
className={clsx(
'w-4 h-4 rounded-full',
isRecording
? 'animate-pulse bg-primary-400'
: 'bg-success-400'
)}
/>
<span>: {formatTime(recordingTime)}</span>
</div>
)}
{showPreview && audioPreview && (
<audio controls src={audioPreview} />
)}
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
<input
type='file'
ref={audioInputRef}
hidden
accept='audio/*'
className='hidden'
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const dataURL = event.target?.result;
showAudioSegment(dataURL as string);
e.target.value = '';
};
}}
/>
</>
);
};
export default AudioInsert;

View File

@@ -1,31 +0,0 @@
import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip';
import { BsDice3Fill } from 'react-icons/bs';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
const DiceInsert = () => {
const showStructuredMessage = useShowStructuredMessage();
return (
<Tooltip content='发送骰子'>
<Button
color='primary'
variant='flat'
isIconOnly
radius='full'
onPress={() => {
showStructuredMessage([
{
type: 'dice',
},
]);
}}
>
<BsDice3Fill className='text-lg' />
</Button>
</Tooltip>
);
};
export default DiceInsert;

View File

@@ -1,83 +0,0 @@
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Tooltip } from '@heroui/tooltip';
import { data, getUrl } from 'qface';
import { useEffect, useRef, useState } from 'react';
import { MdEmojiEmotions } from 'react-icons/md';
import { EmojiValue } from '../formats/emoji_blot';
const emojis = data.map((item) => {
return {
alt: item.QDes,
src: getUrl(item.QSid),
id: item.QSid,
} as EmojiValue;
});
export interface EmojiPickerProps {
onInsertEmoji: (emoji: EmojiValue) => void
onOpenChange: (open: boolean) => void
}
const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => {
const [visibleEmojis, setVisibleEmojis] = useState<EmojiValue[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isPopoverOpen) {
setVisibleEmojis([]); // Reset visible emojis
requestAnimationFrame(() => loadEmojis()); // Start loading emojis
}
}, [isPopoverOpen]);
const loadEmojis = (index = 0, batchSize = 10) => {
if (index < emojis.length) {
setVisibleEmojis((prev) => [
...prev,
...emojis.slice(index, index + batchSize),
]);
requestAnimationFrame(() => loadEmojis(index + batchSize, batchSize));
}
};
return (
<div ref={containerRef}>
<Popover
portalContainer={containerRef.current!}
shouldCloseOnScroll={false}
placement='right-start'
onOpenChange={(v) => {
onOpenChange(v);
setIsPopoverOpen(v);
}}
>
<Tooltip content='插入表情'>
<div className='max-w-fit'>
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<MdEmojiEmotions className='text-xl' />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='grid grid-cols-8 gap-1 flex-wrap justify-start items-start overflow-y-auto max-w-full max-h-96 p-2'>
{visibleEmojis.map((emoji) => (
<Button
key={emoji.id}
color='primary'
variant='flat'
isIconOnly
radius='full'
onPress={() => onInsertEmoji(emoji)}
>
<Image src={emoji.src} alt={emoji.alt} className='w-6 h-6' />
</Button>
))}
</PopoverContent>
</Popover>
</div>
);
};
export default EmojiPicker;

View File

@@ -1,125 +0,0 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Tooltip } from '@heroui/tooltip';
import { useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { FaFolder } from 'react-icons/fa6';
import { LuFilePlus2 } from 'react-icons/lu';
import { MdEdit, MdUpload } from 'react-icons/md';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
import { isURI } from '@/utils/url';
import type { OB11Segment } from '@/types/onebot';
const FileInsert = () => {
const [fileUrl, setFileUrl] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
const showStructuredMessage = useShowStructuredMessage();
const showFileSegment = (file: string) => {
const messages: OB11Segment[] = [
{
type: 'file',
data: {
file,
},
},
];
showStructuredMessage(messages);
};
return (
<>
<Popover>
<Tooltip content='发送文件'>
<div className='max-w-fit'>
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<FaFolder className='text-lg' />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Tooltip content='上传文件'>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
onPress={() => {
fileInputRef?.current?.click();
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content='输入文件地址'>
<div className='max-w-fit'>
<PopoverTrigger tooltip='输入文件地址'>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-1 p-2'>
<Input
value={fileUrl}
onChange={(e) => setFileUrl(e.target.value)}
placeholder='请输入文件地址'
/>
<Button
color='primary'
variant='flat'
isIconOnly
radius='full'
onPress={() => {
if (!isURI(fileUrl)) {
toast.error('请输入正确的文件地址');
return;
}
showFileSegment(fileUrl);
setFileUrl('');
}}
>
<LuFilePlus2 />
</Button>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
<input
type='file'
ref={fileInputRef}
hidden
className='hidden'
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const dataURL = event.target?.result;
showFileSegment(dataURL as string);
e.target.value = '';
};
}}
/>
</>
);
};
export default FileInsert;

View File

@@ -1,114 +0,0 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Tooltip } from '@heroui/tooltip';
import { useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { MdAddPhotoAlternate, MdEdit, MdImage, MdUpload } from 'react-icons/md';
import { isURI } from '@/utils/url';
export interface ImageInsertProps {
insertImage: (url: string) => void
onOpenChange: (open: boolean) => void
}
const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
const [imgUrl, setImgUrl] = useState<string>('');
const imageInputRef = useRef<HTMLInputElement>(null);
return (
<>
<Popover onOpenChange={onOpenChange}>
<Tooltip content='插入图片'>
<div className='max-w-fit'>
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<MdImage className='text-xl' />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Tooltip content='上传图片'>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
onPress={() => {
imageInputRef?.current?.click();
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content='输入图片地址'>
<div className='max-w-fit'>
<PopoverTrigger tooltip='输入图片地址'>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-1 p-2'>
<Input
value={imgUrl}
onChange={(e) => setImgUrl(e.target.value)}
placeholder='请输入图片地址'
/>
<Button
color='primary'
variant='flat'
isIconOnly
radius='full'
onPress={() => {
if (!isURI(imgUrl)) {
toast.error('请输入正确的图片地址');
return;
}
insertImage(imgUrl);
setImgUrl('');
}}
>
<MdAddPhotoAlternate />
</Button>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
<input
type='file'
ref={imageInputRef}
hidden
accept='image/*'
className='hidden'
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const dataURL = event.target?.result;
insertImage(dataURL as string);
e.target.value = '';
};
}}
/>
</>
);
};
export default ImageInsert;

View File

@@ -1,258 +0,0 @@
import { Button } from '@heroui/button';
import { Form } from '@heroui/form';
import { Input } from '@heroui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Select, SelectItem } from '@heroui/select';
import type { SharedSelection } from '@heroui/system';
import { Tab, Tabs } from '@heroui/tabs';
import { Tooltip } from '@heroui/tooltip';
import type { Key } from '@react-types/shared';
import { useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { IoMusicalNotes } from 'react-icons/io5';
import { TbMusicPlus } from 'react-icons/tb';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
import { isURI } from '@/utils/url';
import type {
CustomMusicSegment,
MusicSegment,
OB11Segment,
} from '@/types/onebot';
type MusicData = CustomMusicSegment['data'] | MusicSegment['data'];
const MusicInsert = () => {
const [musicId, setMusicId] = useState<string>('');
const [musicType, setMusicType] = useState<SharedSelection>(new Set(['163']));
const [mode, setMode] = useState<Key>('default');
const containerRef = useRef<HTMLDivElement>(null);
const { control, handleSubmit, reset } = useForm<
Omit<CustomMusicSegment['data'], 'type'>
>({
defaultValues: {
url: '',
audio: '',
title: '',
image: '',
content: '',
},
});
const showStructuredMessage = useShowStructuredMessage();
const showMusicSegment = (data: MusicData) => {
const messages: OB11Segment[] = [];
if (data.type === 'custom') {
messages.push({
type: 'music',
data: {
...data,
type: 'custom',
},
});
} else {
messages.push({
type: 'music',
data,
});
}
showStructuredMessage(messages);
};
const onSubmit = (data: Omit<CustomMusicSegment['data'], 'type'>) => {
showMusicSegment({
type: 'custom',
...data,
});
reset();
};
return (
<div ref={containerRef} className='overflow-visible'>
<Popover
placement='right-start'
shouldCloseOnScroll={false}
portalContainer={containerRef.current!}
>
<Tooltip content='发送音乐'>
<div className='max-w-fit'>
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<IoMusicalNotes className='text-xl' />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='gap-2 p-4'>
<Tabs
placement='top'
className='w-96'
fullWidth
selectedKey={mode}
onSelectionChange={(key) => {
if (key !== null) setMode(key);
}}
>
<Tab title='主流平台' key='default' className='flex flex-col gap-2'>
<Select
onClick={(e) => e.stopPropagation()}
aria-label='音乐平台'
selectedKeys={musicType}
label='音乐平台'
placeholder='请选择音乐平台'
items={[
{
name: 'QQ音乐',
id: 'qq',
},
{
name: '网易云音乐',
id: '163',
},
{
name: '虾米音乐',
id: 'xm',
},
]}
onSelectionChange={setMusicType}
>
{(item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
)}
</Select>
<Input
value={musicId}
onChange={(e) => setMusicId(e.target.value)}
placeholder='请输入音乐ID'
label='音乐ID'
/>
<Button
fullWidth
size='lg'
color='primary'
variant='flat'
radius='full'
onPress={() => {
if (!musicId) {
toast.error('请输入音乐ID');
return;
}
showMusicSegment({
type: Array.from(
musicType
)[0] as MusicSegment['data']['type'],
id: musicId,
});
setMusicId('');
}}
startContent={<TbMusicPlus />}
>
{Array.from(musicType)[0] === '163' ? '网易云' : 'QQ'}
</Button>
</Tab>
<Tab
title='自定义音乐'
key='custom'
className='flex flex-col gap-2'
>
<Form
onSubmit={handleSubmit(onSubmit)}
className='flex flex-col gap-2'
validationBehavior='native'
>
<Controller
name='url'
control={control}
render={({ field }) => (
<Input
{...field}
isRequired
validate={(v) => {
return !isURI(v) ? '请输入正确的音乐URL' : null;
}}
size='sm'
placeholder='请输入音乐URL'
label='音乐URL'
/>
)}
/>
<Controller
name='audio'
control={control}
render={({ field }) => (
<Input
{...field}
isRequired
validate={(v) => {
return !isURI(v) ? '请输入正确的音频URL' : null;
}}
size='sm'
placeholder='请输入音频URL'
label='音频URL'
/>
)}
/>
<Controller
name='title'
control={control}
render={({ field }) => (
<Input
{...field}
isRequired
size='sm'
errorMessage='请输入音乐标题'
placeholder='请输入音乐标题'
label='音乐标题'
/>
)}
/>
<Controller
name='image'
control={control}
render={({ field }) => (
<Input
{...field}
size='sm'
placeholder='请输入封面图片URL'
label='封面图片URL'
/>
)}
/>
<Controller
name='content'
control={control}
render={({ field }) => (
<Input
{...field}
size='sm'
placeholder='请输入音乐描述'
label='音乐描述'
/>
)}
/>
<Button
fullWidth
size='lg'
color='primary'
variant='flat'
radius='full'
type='submit'
startContent={<TbMusicPlus />}
>
</Button>
</Form>
</Tab>
</Tabs>
</PopoverContent>
</Popover>
</div>
);
};
export default MusicInsert;

View File

@@ -1,58 +0,0 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Tooltip } from '@heroui/tooltip';
import { useState } from 'react';
import { BsChatQuoteFill } from 'react-icons/bs';
import { MdAdd } from 'react-icons/md';
export interface ReplyInsertProps {
insertReply: (messageId: string) => void
}
const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
const [replyId, setReplyId] = useState<string>('');
return (
<>
<Popover>
<Tooltip content='回复消息'>
<div className='max-w-fit'>
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<BsChatQuoteFill className='text-lg' />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Input
placeholder='输入消息 ID'
value={replyId}
onChange={(e) => {
const value = e.target.value;
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/;
if (isNumberReg.test(value)) {
setReplyId(value);
}
}}
/>
<Button
color='primary'
variant='flat'
radius='full'
isIconOnly
onPress={() => {
insertReply(replyId);
setReplyId('');
}}
>
<MdAdd />
</Button>
</PopoverContent>
</Popover>
</>
);
};
export default ReplyInsert;

View File

@@ -1,31 +0,0 @@
import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip';
import { LiaHandScissors } from 'react-icons/lia';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
const RPSInsert = () => {
const showStructuredMessage = useShowStructuredMessage();
return (
<Tooltip content='发送猜拳'>
<Button
color='primary'
variant='flat'
isIconOnly
radius='full'
onPress={() => {
showStructuredMessage([
{
type: 'rps',
},
]);
}}
>
<LiaHandScissors className='text-2xl' />
</Button>
</Tooltip>
);
};
export default RPSInsert;

View File

@@ -1,32 +0,0 @@
import { Snippet } from '@heroui/snippet';
import { OB11Segment } from '@/types/onebot';
export interface ShowStructedMessageProps {
messages: OB11Segment[]
}
const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => {
return (
<Snippet
hideSymbol
tooltipProps={{
content: '点击复制',
}}
classNames={{
copyButton: 'self-start sticky top-0 right-0',
}}
className='bg-content1 h-96 overflow-y-scroll items-start'
>
{JSON.stringify(messages, null, 2)
.split('\n')
.map((line, i) => (
<span key={i} className='whitespace-pre-wrap break-all'>
{line}
</span>
))}
</Snippet>
);
};
export default ShowStructedMessage;

View File

@@ -1,126 +0,0 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover';
import { Tooltip } from '@heroui/tooltip';
import { useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { IoVideocam } from 'react-icons/io5';
import { MdEdit, MdUpload } from 'react-icons/md';
import { TbVideoPlus } from 'react-icons/tb';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
import { isURI } from '@/utils/url';
import type { OB11Segment } from '@/types/onebot';
const VideoInsert = () => {
const [videoUrl, setVideoUrl] = useState<string>('');
const videoInputRef = useRef<HTMLInputElement>(null);
const showStructuredMessage = useShowStructuredMessage();
const showVideoSegment = (file: string) => {
const messages: OB11Segment[] = [
{
type: 'video',
data: {
file,
},
},
];
showStructuredMessage(messages);
};
return (
<>
<Popover>
<Tooltip content='发送视频'>
<div className='max-w-fit'>
<PopoverTrigger>
<Button color='primary' variant='flat' isIconOnly radius='full'>
<IoVideocam className='text-xl' />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-2 p-4'>
<Tooltip content='上传视频'>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
onPress={() => {
videoInputRef?.current?.click();
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content='输入视频地址'>
<div className='max-w-fit'>
<PopoverTrigger tooltip='输入视频地址'>
<Button
className='text-lg'
color='primary'
isIconOnly
variant='flat'
radius='full'
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className='flex-row gap-1 p-2'>
<Input
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
placeholder='请输入视频地址'
/>
<Button
color='primary'
variant='flat'
isIconOnly
radius='full'
onPress={() => {
if (!isURI(videoUrl)) {
toast.error('请输入正确的视频地址');
return;
}
showVideoSegment(videoUrl);
setVideoUrl('');
}}
>
<TbVideoPlus />
</Button>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
<input
type='file'
ref={videoInputRef}
hidden
accept='video/*'
className='hidden'
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const dataURL = event.target?.result;
showVideoSegment(dataURL as string);
e.target.value = '';
};
}}
/>
</>
);
};
export default VideoInsert;

View File

@@ -1,41 +0,0 @@
import Quill from 'quill';
// eslint-disable-next-line
const Embed = Quill.import('blots/embed') as any
export interface EmojiValue {
alt: string
src: string
id: string
}
class EmojiBlot extends Embed {
static blotName: string = 'emoji';
static tagName: string = 'img';
static classNames: string[] = ['w-6', 'h-6'];
static create (value: HTMLImageElement) {
const node = super.create(value);
node.setAttribute('alt', value.alt);
node.setAttribute('src', value.src);
node.setAttribute('data-id', value.id);
node.classList.add(...EmojiBlot.classNames);
return node;
}
static formats (node: HTMLImageElement): EmojiValue {
return {
alt: node.getAttribute('alt') ?? '',
src: node.getAttribute('src') ?? '',
id: node.getAttribute('data-id') ?? '',
};
}
static value (node: HTMLImageElement): EmojiValue {
return {
alt: node.getAttribute('alt') ?? '',
src: node.getAttribute('src') ?? '',
id: node.getAttribute('data-id') ?? '',
};
}
}
export default EmojiBlot;

View File

@@ -1,30 +0,0 @@
import Quill from 'quill';
// eslint-disable-next-line
const Embed = Quill.import('blots/embed') as any
export interface ImageValue {
alt: string
src: string
}
class ImageBlot extends Embed {
static blotName = 'image';
static tagName = 'img';
static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom'];
static create (value: ImageValue) {
const node = super.create();
node.setAttribute('alt', value.alt);
node.setAttribute('src', value.src);
node.classList.add(...ImageBlot.classNames);
return node;
}
static value (node: HTMLImageElement): ImageValue {
return {
alt: node.getAttribute('alt') ?? '',
src: node.getAttribute('src') ?? '',
};
}
}
export default ImageBlot;

View File

@@ -1,43 +0,0 @@
import Quill from 'quill';
// eslint-disable-next-line
const BlockEmbed = Quill.import('blots/block/embed') as any
export interface ReplyBlockValue {
messageId: string
}
class ReplyBlock extends BlockEmbed {
static blotName = 'reply';
static tagName = 'div';
static classNames = [
'p-2',
'select-none',
'bg-default-100',
'rounded-md',
'pointer-events-none',
];
static create (value: ReplyBlockValue) {
const node = super.create();
node.setAttribute('data-message-id', value.messageId);
node.setAttribute('contenteditable', 'false');
node.classList.add(...ReplyBlock.classNames);
const innerDom = document.createElement('div');
innerDom.classList.add('text-sm', 'text-default-500', 'relative');
const svgContainer = document.createElement('div');
svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0');
const svg = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M15.9082 12.3714H20.5982C20.5182 17.0414 19.5982 17.8114 16.7282 19.5114C16.3982 19.7114 16.2882 20.1314 16.4882 20.4714C16.6882 20.8014 17.1082 20.9114 17.4482 20.7114C20.8282 18.7114 22.0082 17.4914 22.0082 11.6714V6.28141C22.0082 4.57141 20.6182 3.19141 18.9182 3.19141H15.9182C14.1582 3.19141 12.8282 4.52141 12.8282 6.28141V9.28141C12.8182 11.0414 14.1482 12.3714 15.9082 12.3714Z" fill="#292D32"></path> <path d="M5.09 12.3714H9.78C9.7 17.0414 8.78 17.8114 5.91 19.5114C5.58 19.7114 5.47 20.1314 5.67 20.4714C5.87 20.8014 6.29 20.9114 6.63 20.7114C10.01 18.7114 11.19 17.4914 11.19 11.6714V6.28141C11.19 4.57141 9.8 3.19141 8.1 3.19141H5.1C3.33 3.19141 2 4.52141 2 6.28141V9.28141C2 11.0414 3.33 12.3714 5.09 12.3714Z" fill="#292D32"></path> </g></svg>';
svgContainer.innerHTML = svg;
innerDom.innerHTML = `消息ID${value.messageId}`;
innerDom.appendChild(svgContainer);
node.appendChild(innerDom);
return node;
}
static value (node: HTMLElement): ReplyBlockValue {
return {
messageId: node.getAttribute('data-message-id') || '',
};
}
}
export default ReplyBlock;

View File

@@ -1,207 +0,0 @@
import { Button } from '@heroui/button';
import type { Range } from 'quill';
import 'quill/dist/quill.core.css';
import { useRef } from 'react';
import toast from 'react-hot-toast';
import { useCustomQuill } from '@/hooks/use_custom_quill';
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message';
import { quillToMessage } from '@/utils/onebot';
import type { OB11Segment } from '@/types/onebot';
import AudioInsert from './components/audio_insert';
import DiceInsert from './components/dice_insert';
import EmojiPicker from './components/emoji_picker';
import FileInsert from './components/file_insert';
import ImageInsert from './components/image_insert';
import MusicInsert from './components/music_insert';
import ReplyInsert from './components/reply_insert';
import RPSInsert from './components/rps_insert';
import VideoInsert from './components/video_insert';
import EmojiBlot from './formats/emoji_blot';
import type { EmojiValue } from './formats/emoji_blot';
import ImageBlot from './formats/image_blot';
import ReplyBlock from './formats/reply_blot';
const ChatInput = () => {
const memorizedRange = useRef<Range | null>(null);
const showStructuredMessage = useShowStructuredMessage();
const formats: string[] = ['image', 'emoji', 'reply'];
const modules = {
toolbar: '#toolbar',
};
const { quillRef, quill, Quill } = useCustomQuill({
modules,
formats,
placeholder: '请输入消息',
});
if (Quill && !quill) {
Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/image', ImageBlot, true);
Quill.register('formats/reply', ReplyBlock);
}
if (quill) {
quill.on('selection-change', (range) => {
if (range) {
const editorContent = quill.getContents();
const firstOp = editorContent.ops[0];
if (
typeof firstOp?.insert !== 'string' &&
firstOp?.insert?.reply &&
range.index === 0 &&
range.length !== quill.getLength()
) {
quill.setSelection(1, Quill.sources.SILENT);
}
}
});
quill.on('text-change', () => {
const editorContent = quill.getContents();
const firstOp = editorContent.ops[0];
if (
firstOp &&
typeof firstOp.insert !== 'string' &&
firstOp.insert?.reply &&
quill.getLength() === 1
) {
quill.insertText(1, '\n', Quill.sources.SILENT);
}
});
quill.on('editor-change', (eventName: string) => {
if (eventName === 'text-change') {
const editorContent = quill.getContents();
const firstOp = editorContent.ops[0];
if (
firstOp &&
typeof firstOp.insert !== 'string' &&
firstOp.insert?.reply &&
quill.getLength() === 1
) {
quill.insertText(1, '\n', Quill.sources.SILENT);
}
}
});
quill.root.addEventListener('compositionstart', () => {
const editorContent = quill.getContents();
const firstOp = editorContent.ops[0];
if (
firstOp &&
typeof firstOp.insert !== 'string' &&
firstOp.insert?.reply &&
quill.getLength() === 1
) {
quill.insertText(1, '\n', Quill.sources.SILENT);
}
});
}
const onOpenChange = (open: boolean) => {
if (open) {
const selection = quill?.getSelection();
if (selection) memorizedRange.current = selection;
}
};
const insertImage = (url: string) => {
const selection = memorizedRange.current || quill?.getSelection();
quill?.deleteText(selection?.index || 0, selection?.length || 0);
quill?.insertEmbed(selection?.index || 0, 'image', {
src: url,
alt: '图片',
});
quill?.setSelection((selection?.index || 0) + 1, 0);
};
function insertReplyBlock (messageId: string) {
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/;
if (!isNumberReg.test(messageId)) {
toast.error('请输入正确的消息ID');
return;
}
const editorContent = quill?.getContents();
const firstOp = editorContent?.ops[0];
const currentSelection = quill?.getSelection();
if (
firstOp &&
typeof firstOp.insert !== 'string' &&
firstOp.insert?.reply
) {
const delta = quill?.getContents();
if (delta) {
delta.ops[0] = {
insert: { reply: { messageId } },
};
quill?.setContents(delta, Quill.sources.USER);
}
} else {
quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER);
}
quill?.setSelection((currentSelection?.index || 0) + 1, 0);
quill?.blur();
}
const onInsertEmoji = (emoji: EmojiValue) => {
const selection = memorizedRange.current || quill?.getSelection();
quill?.deleteText(selection?.index || 0, selection?.length || 0);
quill?.insertEmbed(selection?.index || 0, 'emoji', {
alt: emoji.alt,
src: emoji.src,
id: emoji.id,
});
quill?.setSelection((selection?.index || 0) + 1, 0);
};
const getChatMessage = () => {
const delta = quill?.getContents();
const ops =
delta?.ops?.filter((op) => {
return op.insert !== '\n';
}) ?? [];
const messages: OB11Segment[] = ops.map((op) => {
return quillToMessage(op);
});
return messages;
};
return (
<div>
<div
ref={quillRef}
className='border border-default-200 rounded-md !mb-2 !text-base !h-64'
/>
<div id='toolbar' className='!border-none flex gap-2'>
<ImageInsert insertImage={insertImage} onOpenChange={onOpenChange} />
<EmojiPicker
onInsertEmoji={onInsertEmoji}
onOpenChange={onOpenChange}
/>
<ReplyInsert insertReply={insertReplyBlock} />
<FileInsert />
<AudioInsert />
<VideoInsert />
<MusicInsert />
<DiceInsert />
<RPSInsert />
<Button
color='primary'
onPress={() => {
const messages = getChatMessage();
showStructuredMessage(messages);
}}
className='ml-auto'
>
JSON格式
</Button>
</div>
</div>
);
};
export default ChatInput;

View File

@@ -1,49 +0,0 @@
import { Button } from '@heroui/button';
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure,
} from '@heroui/modal';
import ChatInput from '.';
export default function ChatInputModal () {
const { isOpen, onOpen, onOpenChange } = useDisclosure();
return (
<>
<Button onPress={onOpen} color='primary' radius='full' variant='flat'>
</Button>
<Modal
size='4xl'
scrollBehavior='inside'
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className='flex flex-col gap-1'>
</ModalHeader>
<ModalBody className='overflow-y-auto'>
<div className='overflow-y-auto'>
<ChatInput />
</div>
</ModalBody>
<ModalFooter>
<Button color='primary' onPress={onClose} variant='flat'>
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
}

View File

@@ -1,55 +0,0 @@
import Editor, { OnMount, loader } from '@monaco-editor/react';
import React from 'react';
import { useTheme } from '@/hooks/use-theme';
import monaco from '@/monaco';
loader.config({
monaco,
paths: {
vs: '/webui/monaco-editor/min/vs',
},
});
loader.config({
'vs/nls': {
availableLanguages: { '*': 'zh-cn' },
},
});
export interface CodeEditorProps extends React.ComponentProps<typeof Editor> {
test?: string
}
export type CodeEditorRef = monaco.editor.IStandaloneCodeEditor;
const CodeEditor = React.forwardRef<CodeEditorRef, CodeEditorProps>(
(props, ref) => {
const { isDark } = useTheme();
const handleEditorDidMount: OnMount = (editor, monaco) => {
if (ref) {
if (typeof ref === 'function') {
ref(editor);
} else {
(ref as React.RefObject<CodeEditorRef>).current = editor;
}
}
if (props.onMount) {
props.onMount(editor, monaco);
}
};
return (
<Editor
{...props}
onMount={handleEditorDidMount}
theme={isDark ? 'vs-dark' : 'light'}
/>
);
}
);
export default CodeEditor;

View File

@@ -1,137 +0,0 @@
import { Button, ButtonGroup } from '@heroui/button';
import { Switch } from '@heroui/switch';
import { useState } from 'react';
import { CgDebug } from 'react-icons/cg';
import { FiEdit3 } from 'react-icons/fi';
import { MdDeleteForever } from 'react-icons/md';
import DisplayCardContainer from './container';
type NetworkType = OneBotConfig['network'];
export type NetworkDisplayCardFields<T extends keyof NetworkType> = Array<{
label: string
value: NetworkType[T][0][keyof NetworkType[T][0]]
render?: (
value: NetworkType[T][0][keyof NetworkType[T][0]]
) => React.ReactNode
}>;
export interface NetworkDisplayCardProps<T extends keyof NetworkType> {
data: NetworkType[T][0]
showType?: boolean
typeLabel: string
fields: NetworkDisplayCardFields<T>
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const NetworkDisplayCard = <T extends keyof NetworkType>({
data,
showType,
typeLabel,
fields,
onEdit,
onEnable,
onDelete,
onEnableDebug,
}: NetworkDisplayCardProps<T>) => {
const { name, enable, debug } = data;
const [editing, setEditing] = useState(false);
const handleEnable = () => {
setEditing(true);
onEnable().finally(() => setEditing(false));
};
const handleDelete = () => {
setEditing(true);
onDelete().finally(() => setEditing(false));
};
const handleEnableDebug = () => {
setEditing(true);
onEnableDebug().finally(() => setEditing(false));
};
return (
<DisplayCardContainer
action={
<ButtonGroup
fullWidth
isDisabled={editing}
radius='sm'
size='sm'
variant='flat'
>
<Button
color='warning'
startContent={<FiEdit3 size={16} />}
onPress={onEdit}
>
</Button>
<Button
color={debug ? 'secondary' : 'success'}
variant='flat'
startContent={
<CgDebug
style={{
width: '16px',
height: '16px',
minWidth: '16px',
minHeight: '16px',
}}
/>
}
onPress={handleEnableDebug}
>
{debug ? '关闭调试' : '开启调试'}
</Button>
<Button
className='bg-danger/20 text-danger hover:bg-danger/30 transition-colors'
variant='flat'
startContent={<MdDeleteForever size={16} />}
onPress={handleDelete}
>
</Button>
</ButtonGroup>
}
enableSwitch={
<Switch
isDisabled={editing}
isSelected={enable}
onChange={handleEnable}
/>
}
tag={showType && typeLabel}
title={name}
>
<div className='grid grid-cols-2 gap-1'>
{fields.map((field, index) => (
<div
key={index}
className={`flex items-center gap-2 ${
field.label === 'URL' ? 'col-span-2' : ''
}`}
>
<span className='text-default-400'>{field.label}</span>
{field.render
? (
field.render(field.value)
)
: (
<span>{field.value}</span>
)}
</div>
))}
</div>
</DisplayCardContainer>
);
};
export default NetworkDisplayCard;

View File

@@ -1,57 +0,0 @@
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card';
import clsx from 'clsx';
import { title } from '../primitives';
export interface ContainerProps {
title: string
tag?: React.ReactNode
action: React.ReactNode
enableSwitch: React.ReactNode
children: React.ReactNode
}
export interface DisplayCardProps {
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const DisplayCardContainer: React.FC<ContainerProps> = ({
title: _title,
action,
tag,
enableSwitch,
children,
}) => {
return (
<Card className='bg-opacity-50 backdrop-blur-sm'>
<CardHeader className='pb-0 flex items-center'>
{tag && (
<div className='text-center text-default-400 mb-1 absolute top-0 left-1/2 -translate-x-1/2 text-sm pointer-events-none bg-warning-100 dark:bg-warning-50 px-2 rounded-b'>
{tag}
</div>
)}
<h2
className={clsx(
title({
color: 'foreground',
size: 'xs',
shadow: true,
}),
'truncate'
)}
>
{_title}
</h2>
<div className='ml-auto'>{enableSwitch}</div>
</CardHeader>
<CardBody className='text-sm'>{children}</CardBody>
<CardFooter>{action}</CardFooter>
</Card>
);
};
export default DisplayCardContainer;

View File

@@ -1,47 +0,0 @@
import { Chip } from '@heroui/chip';
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface HTTPClientDisplayCardProps {
data: OneBotConfig['network']['httpClients'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const HTTPClientDisplayCard: React.FC<HTTPClientDisplayCardProps> = (props) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const { url, reportSelfMessage, messagePostFormat } = data;
const fields: NetworkDisplayCardFields<'httpClients'> = [
{ label: 'URL', value: url },
{ label: '消息格式', value: messagePostFormat },
{
label: '上报自身消息',
value: reportSelfMessage,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
{value ? '是' : '否'}
</Chip>
),
},
];
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='HTTP客户端'
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
export default HTTPClientDisplayCard;

View File

@@ -1,57 +0,0 @@
import { Chip } from '@heroui/chip';
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface HTTPServerDisplayCardProps {
data: OneBotConfig['network']['httpServers'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const HTTPServerDisplayCard: React.FC<HTTPServerDisplayCardProps> = (props) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data;
const fields: NetworkDisplayCardFields<'httpServers'> = [
{ label: '主机', value: host },
{ label: '端口', value: port },
{ label: '消息格式', value: messagePostFormat },
{
label: 'CORS',
value: enableCors,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
{value ? '已启用' : '未启用'}
</Chip>
),
},
{
label: 'WS',
value: enableWebsocket,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
{value ? '已启用' : '未启用'}
</Chip>
),
},
];
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='HTTP服务器'
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
export default HTTPServerDisplayCard;

View File

@@ -1,59 +0,0 @@
import { Chip } from '@heroui/chip';
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface HTTPSSEServerDisplayCardProps {
data: OneBotConfig['network']['httpSseServers'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const HTTPSSEServerDisplayCard: React.FC<HTTPSSEServerDisplayCardProps> = (
props
) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const { host, port, enableCors, enableWebsocket, messagePostFormat } = data;
const fields: NetworkDisplayCardFields<'httpServers'> = [
{ label: '主机', value: host },
{ label: '端口', value: port },
{ label: '消息格式', value: messagePostFormat },
{
label: 'CORS',
value: enableCors,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
{value ? '已启用' : '未启用'}
</Chip>
),
},
{
label: 'WS',
value: enableWebsocket,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
{value ? '已启用' : '未启用'}
</Chip>
),
},
];
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='HTTP服务器'
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
export default HTTPSSEServerDisplayCard;

View File

@@ -1,57 +0,0 @@
import { Chip } from '@heroui/chip';
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketClientDisplayCardProps {
data: OneBotConfig['network']['websocketClients'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
props
) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const {
url,
heartInterval,
reconnectInterval,
messagePostFormat,
reportSelfMessage,
} = data;
const fields: NetworkDisplayCardFields<'websocketClients'> = [
{ label: 'URL', value: url },
{ label: '重连间隔', value: `${reconnectInterval}ms` },
{ label: '心跳间隔', value: `${heartInterval}ms` },
{ label: '消息格式', value: messagePostFormat },
{
label: '上报自身消息',
value: reportSelfMessage,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
{value ? '是' : '否'}
</Chip>
),
},
];
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='Websocket客户端'
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
export default WebsocketClientDisplayCard;

View File

@@ -1,67 +0,0 @@
import { Chip } from '@heroui/chip';
import NetworkDisplayCard from './common_card';
import type { NetworkDisplayCardFields } from './common_card';
interface WebsocketServerDisplayCardProps {
data: OneBotConfig['network']['websocketServers'][0]
showType?: boolean
onEdit: () => void
onEnable: () => Promise<void>
onDelete: () => Promise<void>
onEnableDebug: () => Promise<void>
}
const WebsocketServerDisplayCard: React.FC<WebsocketServerDisplayCardProps> = (
props
) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props;
const {
host,
port,
heartInterval,
messagePostFormat,
reportSelfMessage,
enableForcePushEvent,
} = data;
const fields: NetworkDisplayCardFields<'websocketServers'> = [
{ label: '主机', value: host },
{ label: '端口', value: port },
{ label: '心跳间隔', value: `${heartInterval}ms` },
{ label: '消息格式', value: messagePostFormat },
{
label: '上报自身消息',
value: reportSelfMessage,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
{value ? '是' : '否'}
</Chip>
),
},
{
label: '强制推送事件',
value: enableForcePushEvent,
render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'>
{value ? '是' : '否'}
</Chip>
),
},
];
return (
<NetworkDisplayCard
data={data}
showType={showType}
typeLabel='Websocket服务器'
fields={fields}
onEdit={onEdit}
onEnable={onEnable}
onDelete={onDelete}
onEnableDebug={onEnableDebug}
/>
);
};
export default WebsocketServerDisplayCard;

View File

@@ -1,58 +0,0 @@
import { Card, CardBody } from '@heroui/card';
import clsx from 'clsx';
import { title } from '@/components/primitives';
export interface NetworkItemDisplayProps {
count: number
label: string
size?: 'sm' | 'md'
}
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
count,
label,
size = 'md',
}) => {
return (
<Card
className={clsx(
'bg-opacity-60 shadow-sm md:rounded-3xl',
size === 'md'
? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100'
: 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
)}
shadow='sm'
>
<CardBody className='items-center md:gap-1 p-1 md:p-2'>
<div
className={clsx(
'flex-1',
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
title({
color: size === 'md' ? 'pink' : 'yellow',
size,
})
)}
>
{count}
</div>
<div
className={clsx(
'whitespace-nowrap text-nowrap flex-shrink-0',
size === 'md' ? 'text-sm md:text-base' : 'text-xs md:text-sm',
title({
color: size === 'md' ? 'pink' : 'yellow',
shadow: true,
size: 'xxs',
})
)}
>
{label}
</div>
</CardBody>
</Card>
);
};
export default NetworkItemDisplay;

View File

@@ -1,109 +0,0 @@
import { Card, CardProps } from '@heroui/card';
import clsx from 'clsx';
import React from 'react';
export interface HoverEffectCardProps extends CardProps {
children: React.ReactNode
maxXRotation?: number
maxYRotation?: number
lightClassName?: string
lightStyle?: React.CSSProperties
}
const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
const {
children,
maxXRotation = 5,
maxYRotation = 5,
className,
style,
lightClassName,
lightStyle,
} = props;
const cardRef = React.useRef<HTMLDivElement | null>(null);
const lightRef = React.useRef<HTMLDivElement | null>(null);
const [isShowLight, setIsShowLight] = React.useState(false);
const [pos, setPos] = React.useState({
left: 0,
top: 0,
});
return (
<Card
{...props}
ref={cardRef}
className={clsx(
'relative overflow-hidden bg-opacity-50 backdrop-blur-lg',
className
)}
style={{
willChange: 'transform',
transform:
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)',
...style,
}}
onMouseEnter={() => {
if (cardRef.current) {
cardRef.current.style.transition = 'transform 0.3s ease-out';
}
}}
onMouseLeave={() => {
setIsShowLight(false);
if (cardRef.current) {
cardRef.current.style.transition = 'transform 0.5s';
cardRef.current.style.transform =
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)';
}
}}
onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => {
if (cardRef.current) {
setIsShowLight(true);
const { x, y } = cardRef.current.getBoundingClientRect();
const { clientX, clientY } = e;
const offsetX = clientX - x;
const offsetY = clientY - y;
const lightWidth = lightStyle?.width?.toString() || '100';
const lightHeight = lightStyle?.height?.toString() || '100';
const lightWidthNum = parseInt(lightWidth);
const lightHeightNum = parseInt(lightHeight);
const left = offsetX - lightWidthNum / 2;
const top = offsetY - lightHeightNum / 2;
setPos({
left,
top,
});
cardRef.current.style.transition = 'transform 0.1s';
const rangeX = 400 / 2;
const rangeY = 400 / 2;
const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation;
const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation;
cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
}
}}
>
<div
ref={lightRef}
className={clsx(
isShowLight ? 'opacity-100' : 'opacity-0',
'absolute rounded-full blur-[150px] filter transition-opacity duration-300 dark:bg-[#2850ff] bg-[#ff4132] w-[100px] h-[100px]',
lightClassName
)}
style={{
...pos,
}}
/>
{children}
</Card>
);
};
export default HoverEffectCard;

View File

@@ -1,30 +0,0 @@
import { Button } from '@heroui/button';
import { Code } from '@heroui/code';
import { MdError } from 'react-icons/md';
export interface ErrorFallbackProps {
error: Error
resetErrorBoundary: () => void
}
function errorFallbackRender ({
error,
resetErrorBoundary,
}: ErrorFallbackProps) {
return (
<div className='pt-32 flex flex-col justify-center items-center'>
<div className='flex items-center'>
<MdError className='mr-2' color='red' size={30} />
<h1 className='text-2xl'></h1>
</div>
<div className='my-6 flex flex-col justify-center items-center'>
<p className='mb-2'></p>
<Code>{error.message}</Code>
</div>
<Button color='primary' size='md' onPress={resetErrorBoundary}>
</Button>
</div>
);
}
export default errorFallbackRender;

View File

@@ -1,166 +0,0 @@
import {
FaFile,
FaFileAudio,
FaFileCode,
FaFileCsv,
FaFileExcel,
FaFileImage,
FaFileLines,
FaFilePdf,
FaFilePowerpoint,
FaFileVideo,
FaFileWord,
FaFileZipper,
FaFolderClosed,
} from 'react-icons/fa6';
export interface FileIconProps {
name?: string
isDirectory?: boolean
}
const FileIcon = (props: FileIconProps) => {
const { name, isDirectory = false } = props;
if (isDirectory) {
return <FaFolderClosed className='text-yellow-500' />;
}
const ext = name?.split('.').pop() || '';
if (ext) {
switch (ext.toLowerCase()) {
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'svg':
case 'bmp':
case 'ico':
case 'webp':
case 'tiff':
case 'tif':
case 'heic':
case 'heif':
case 'avif':
case 'apng':
case 'flif':
case 'ai':
case 'psd':
case 'xcf':
case 'sketch':
case 'fig':
case 'xd':
case 'svgz':
return <FaFileImage className='text-green-500' />;
case 'pdf':
return <FaFilePdf className='text-red-500' />;
case 'doc':
case 'docx':
return <FaFileWord className='text-blue-500' />;
case 'xls':
case 'xlsx':
return <FaFileExcel className='text-green-500' />;
case 'csv':
return <FaFileCsv className='text-green-500' />;
case 'ppt':
case 'pptx':
return <FaFilePowerpoint className='text-red-500' />;
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
case 'bz2':
case 'xz':
case 'lz':
case 'lzma':
case 'zst':
case 'zstd':
case 'z':
case 'taz':
case 'tz':
case 'tzo':
return <FaFileZipper className='text-green-500' />;
case 'txt':
return <FaFileLines className='text-gray-500' />;
case 'mp3':
case 'wav':
case 'flac':
return <FaFileAudio className='text-green-500' />;
case 'mp4':
case 'avi':
case 'mov':
case 'wmv':
return <FaFileVideo className='text-red-500' />;
case 'html':
case 'css':
case 'js':
case 'ts':
case 'jsx':
case 'tsx':
case 'json':
case 'xml':
case 'yaml':
case 'yml':
case 'md':
case 'sh':
case 'py':
case 'java':
case 'c':
case 'cpp':
case 'cs':
case 'go':
case 'php':
case 'rb':
case 'pl':
case 'swift':
case 'kt':
case 'rs':
case 'sql':
case 'r':
case 'scala':
case 'groovy':
case 'dart':
case 'lua':
case 'perl':
case 'h':
case 'm':
case 'mm':
case 'makefile':
case 'cmake':
case 'dockerfile':
case 'gradle':
case 'properties':
case 'ini':
case 'conf':
case 'env':
case 'bat':
case 'cmd':
case 'ps1':
case 'psm1':
case 'psd1':
case 'ps1xml':
case 'psc1':
case 'pssc':
case 'nuspec':
case 'resx':
case 'resw':
case 'csproj':
case 'vbproj':
case 'vcxproj':
case 'fsproj':
case 'sln':
case 'suo':
case 'user':
case 'userosscache':
case 'sln.docstates':
case 'dll':
return <FaFileCode className='text-blue-500' />;
default:
return <FaFile className='text-gray-500' />;
}
}
return <FaFile className='text-gray-500' />;
};
export default FileIcon;

View File

@@ -1,64 +0,0 @@
import { Button, ButtonGroup } from '@heroui/button';
import { Input } from '@heroui/input';
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
interface CreateFileModalProps {
isOpen: boolean
fileType: 'file' | 'directory'
newFileName: string
onTypeChange: (type: 'file' | 'directory') => void
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onClose: () => void
onCreate: () => void
}
export default function CreateFileModal ({
isOpen,
fileType,
newFileName,
onTypeChange,
onNameChange,
onClose,
onCreate,
}: CreateFileModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className='flex flex-col gap-4'>
<ButtonGroup color='primary'>
<Button
variant={fileType === 'file' ? 'solid' : 'flat'}
onPress={() => onTypeChange('file')}
>
</Button>
<Button
variant={fileType === 'directory' ? 'solid' : 'flat'}
onPress={() => onTypeChange('directory')}
>
</Button>
</ButtonGroup>
<Input label='名称' value={newFileName} onChange={onNameChange} />
</div>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
</Button>
<Button color='primary' onPress={onCreate}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,94 +0,0 @@
import { Button } from '@heroui/button';
import { Code } from '@heroui/code';
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
import CodeEditor from '@/components/code_editor';
interface FileEditModalProps {
isOpen: boolean
file: { path: string; content: string } | null
onClose: () => void
onSave: () => void
onContentChange: (newContent?: string) => void
}
export default function FileEditModal ({
isOpen,
file,
onClose,
onSave,
onContentChange,
}: FileEditModalProps) {
// 根据文件后缀返回对应语言
const getLanguage = (filePath: string) => {
if (filePath.endsWith('.js')) return 'javascript';
if (filePath.endsWith('.ts')) return 'typescript';
if (filePath.endsWith('.tsx')) return 'tsx';
if (filePath.endsWith('.jsx')) return 'jsx';
if (filePath.endsWith('.vue')) return 'vue';
if (filePath.endsWith('.svelte')) return 'svelte';
if (filePath.endsWith('.json')) return 'json';
if (filePath.endsWith('.html')) return 'html';
if (filePath.endsWith('.css')) return 'css';
if (filePath.endsWith('.scss')) return 'scss';
if (filePath.endsWith('.less')) return 'less';
if (filePath.endsWith('.md')) return 'markdown';
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml';
if (filePath.endsWith('.xml')) return 'xml';
if (filePath.endsWith('.sql')) return 'sql';
if (filePath.endsWith('.sh')) return 'shell';
if (filePath.endsWith('.bat')) return 'bat';
if (filePath.endsWith('.php')) return 'php';
if (filePath.endsWith('.java')) return 'java';
if (filePath.endsWith('.c')) return 'c';
if (filePath.endsWith('.cpp')) return 'cpp';
if (filePath.endsWith('.h')) return 'h';
if (filePath.endsWith('.hpp')) return 'hpp';
if (filePath.endsWith('.go')) return 'go';
if (filePath.endsWith('.py')) return 'python';
if (filePath.endsWith('.rb')) return 'ruby';
if (filePath.endsWith('.cs')) return 'csharp';
if (filePath.endsWith('.swift')) return 'swift';
if (filePath.endsWith('.vb')) return 'vb';
if (filePath.endsWith('.lua')) return 'lua';
if (filePath.endsWith('.pl')) return 'perl';
if (filePath.endsWith('.r')) return 'r';
return 'plaintext';
};
return (
<Modal size='full' isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader className='flex items-center gap-2 bg-content2 bg-opacity-50'>
<span></span>
<Code className='text-xs'>{file?.path}</Code>
</ModalHeader>
<ModalBody className='p-0'>
<div className='h-full'>
<CodeEditor
height='100%'
value={file?.content || ''}
onChange={onContentChange}
options={{ wordWrap: 'on' }}
language={file?.path ? getLanguage(file.path) : 'plaintext'}
/>
</div>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
</Button>
<Button color='primary' onPress={onSave}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,92 +0,0 @@
import { Button } from '@heroui/button';
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
import { Spinner } from '@heroui/spinner';
import { useRequest } from 'ahooks';
import path from 'path-browserify';
import { useEffect } from 'react';
import FileManager from '@/controllers/file_manager';
interface FilePreviewModalProps {
isOpen: boolean
filePath: string
onClose: () => void
}
export const videoExts = ['.mp4', '.webm'];
export const audioExts = ['.mp3', '.wav'];
export const supportedPreviewExts = [...videoExts, ...audioExts];
export default function FilePreviewModal ({
isOpen,
filePath,
onClose,
}: FilePreviewModalProps) {
const ext = path.extname(filePath).toLowerCase();
const { data, loading, error, run } = useRequest(
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase();
if (!filePath || !supportedPreviewExts.includes(ext)) {
return;
}
run();
},
}
);
useEffect(() => {
if (filePath) {
run();
}
}, [filePath]);
let contentElement = null;
if (!supportedPreviewExts.includes(ext)) {
contentElement = <div></div>;
} else if (error) {
contentElement = <div></div>;
} else if (loading || !data) {
contentElement = (
<div className='flex justify-center items-center h-full'>
<Spinner />
</div>
);
} else if (videoExts.includes(ext)) {
contentElement = <video src={data} controls className='max-w-full' />;
} else if (audioExts.includes(ext)) {
contentElement = <audio src={data} controls className='w-full' />;
} else {
contentElement = (
<div className='flex justify-center items-center h-full'>
<Spinner />
</div>
);
}
return (
<Modal isOpen={isOpen} onClose={onClose} scrollBehavior='inside' size='3xl'>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody className='flex justify-center items-center'>
{contentElement}
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,247 +0,0 @@
import { Button, ButtonGroup } from '@heroui/button';
import { Pagination } from '@heroui/pagination';
import { Spinner } from '@heroui/spinner';
import {
type Selection,
type SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from '@heroui/table';
import path from 'path-browserify';
import { useCallback, useEffect, useState } from 'react';
import { BiRename } from 'react-icons/bi';
import { FiCopy, FiDownload, FiMove, FiTrash2 } from 'react-icons/fi';
import { PhotoSlider } from 'react-photo-view';
import FileIcon from '@/components/file_icon';
import type { FileInfo } from '@/controllers/file_manager';
import { supportedPreviewExts } from './file_preview_modal';
import ImageNameButton, { PreviewImage, imageExts } from './image_name_button';
export interface FileTableProps {
files: FileInfo[]
currentPath: string
loading: boolean
sortDescriptor: SortDescriptor
onSortChange: (descriptor: SortDescriptor) => void
selectedFiles: Selection
onSelectionChange: (selected: Selection) => void
onDirectoryClick: (dirPath: string) => void
onEdit: (filePath: string) => void
onPreview: (filePath: string) => void
onRenameRequest: (name: string) => void
onMoveRequest: (name: string) => void
onCopyPath: (fileName: string) => void
onDelete: (filePath: string) => void
onDownload: (filePath: string) => void
}
const PAGE_SIZE = 20;
export default function FileTable ({
files,
currentPath,
loading,
sortDescriptor,
onSortChange,
selectedFiles,
onSelectionChange,
onDirectoryClick,
onEdit,
onPreview,
onRenameRequest,
onMoveRequest,
onCopyPath,
onDelete,
onDownload,
}: FileTableProps) {
const [page, setPage] = useState(1);
const pages = Math.ceil(files.length / PAGE_SIZE) || 1;
const start = (page - 1) * PAGE_SIZE;
const end = start + PAGE_SIZE;
const displayFiles = files.slice(start, end);
const [showImage, setShowImage] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([]);
const addPreviewImage = useCallback((image: PreviewImage) => {
setPreviewImages((prev) => {
const exists = prev.some((p) => p.key === image.key);
if (exists) return prev;
return [...prev, image];
});
}, []);
useEffect(() => {
setPreviewImages([]);
setPreviewIndex(0);
setShowImage(false);
setPage(1);
}, [currentPath]);
const onPreviewImage = (name: string, images: PreviewImage[]) => {
const index = images.findIndex((image) => image.key === name);
if (index === -1) {
return;
}
setPreviewIndex(index);
setShowImage(true);
};
return (
<>
<PhotoSlider
images={previewImages}
visible={showImage}
onClose={() => setShowImage(false)}
index={previewIndex}
onIndexChange={setPreviewIndex}
/>
<Table
aria-label='文件列表'
sortDescriptor={sortDescriptor}
onSortChange={onSortChange}
onSelectionChange={onSelectionChange}
defaultSelectedKeys={[]}
selectedKeys={selectedFiles}
selectionMode='multiple'
bottomContent={
<div className='flex w-full justify-center'>
<Pagination
isCompact
showControls
showShadow
color='primary'
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>
</div>
}
>
<TableHeader>
<TableColumn key='name' allowsSorting>
</TableColumn>
<TableColumn key='type' allowsSorting>
</TableColumn>
<TableColumn key='size' allowsSorting>
</TableColumn>
<TableColumn key='mtime' allowsSorting>
</TableColumn>
<TableColumn key='actions'></TableColumn>
</TableHeader>
<TableBody
isLoading={loading}
loadingContent={
<div className='flex justify-center items-center h-full'>
<Spinner />
</div>
}
>
{displayFiles.map((file: FileInfo) => {
const filePath = path.join(currentPath, file.name);
const ext = path.extname(file.name).toLowerCase();
const previewable = supportedPreviewExts.includes(ext);
const images = previewImages;
return (
<TableRow key={file.name}>
<TableCell>
{imageExts.includes(ext)
? (
<ImageNameButton
name={file.name}
filePath={filePath}
onPreview={() => onPreviewImage(file.name, images)}
onAddPreview={addPreviewImage}
/>
)
: (
<Button
variant='light'
onPress={() =>
file.isDirectory
? onDirectoryClick(file.name)
: previewable
? onPreview(filePath)
: onEdit(filePath)}
className='text-left justify-start'
startContent={
<FileIcon
name={file.name}
isDirectory={file.isDirectory}
/>
}
>
{file.name}
</Button>
)}
</TableCell>
<TableCell>{file.isDirectory ? '目录' : '文件'}</TableCell>
<TableCell>
{isNaN(file.size) || file.isDirectory
? '-'
: `${file.size} 字节`}
</TableCell>
<TableCell>{new Date(file.mtime).toLocaleString()}</TableCell>
<TableCell>
<ButtonGroup size='sm'>
<Button
isIconOnly
color='primary'
variant='flat'
onPress={() => onRenameRequest(file.name)}
>
<BiRename />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
onPress={() => onMoveRequest(file.name)}
>
<FiMove />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
onPress={() => onCopyPath(file.name)}
>
<FiCopy />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
onPress={() => onDownload(filePath)}
>
<FiDownload />
</Button>
<Button
isIconOnly
color='primary'
variant='flat'
onPress={() => onDelete(filePath)}
>
<FiTrash2 />
</Button>
</ButtonGroup>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</>
);
}

View File

@@ -1,92 +0,0 @@
import { Button } from '@heroui/button';
import { Image } from '@heroui/image';
import { Spinner } from '@heroui/spinner';
import { useRequest } from 'ahooks';
import path from 'path-browserify';
import { useEffect } from 'react';
import FileManager from '@/controllers/file_manager';
import FileIcon from '../file_icon';
export interface PreviewImage {
key: string
src: string
alt: string
}
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp'];
export interface ImageNameButtonProps {
name: string
filePath: string
onPreview: () => void
onAddPreview: (image: PreviewImage) => void
}
export default function ImageNameButton ({
name,
filePath,
onPreview,
onAddPreview,
}: ImageNameButtonProps) {
const { data, loading, error, run } = useRequest(
async () => FileManager.downloadToURL(filePath),
{
refreshDeps: [filePath],
manual: true,
refreshDepsAction: () => {
const ext = path.extname(filePath).toLowerCase();
if (!filePath || !imageExts.includes(ext)) {
return;
}
run();
},
}
);
useEffect(() => {
if (data) {
onAddPreview({
key: name,
src: data,
alt: name,
});
}
}, [data, name, onAddPreview]);
useEffect(() => {
if (filePath) {
run();
}
}, []);
return (
<Button
variant='light'
className='text-left justify-start'
onPress={onPreview}
startContent={
error
? (
<FileIcon name={name} isDirectory={false} />
)
: loading || !data
? (
<Spinner size='sm' />
)
: (
<Image
src={data}
alt={name}
className='w-8 h-8 flex-shrink-0'
classNames={{
wrapper: 'w-8 h-8 flex-shrink-0',
}}
radius='sm'
/>
)
}
>
{name}
</Button>
);
}

View File

@@ -1,170 +0,0 @@
import { Button } from '@heroui/button';
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
import { Spinner } from '@heroui/spinner';
import clsx from 'clsx';
import path from 'path-browserify';
import { useState } from 'react';
import { IoAdd, IoRemove } from 'react-icons/io5';
import FileManager from '@/controllers/file_manager';
interface MoveModalProps {
isOpen: boolean;
moveTargetPath: string;
selectionInfo: string;
onClose: () => void;
onMove: () => void;
onSelect: (dir: string) => void; // 新增回调
}
// 将 DirectoryTree 改为递归组件
// 新增 selectedPath 属性,用于标识当前选中的目录
function DirectoryTree ({
basePath,
onSelect,
selectedPath,
}: {
basePath: string;
onSelect: (dir: string) => void;
selectedPath?: string;
}) {
const [dirs, setDirs] = useState<string[]>([]);
const [expanded, setExpanded] = useState(false);
// 新增loading状态
const [loading, setLoading] = useState(false);
const fetchDirectories = async () => {
try {
// 直接使用 basePath 调用接口,移除 process.platform 判断
const list = await FileManager.listDirectories(basePath);
setDirs(list.map((item) => item.name));
} catch (_error) {
// ...error handling...
}
};
const handleToggle = async () => {
if (!expanded) {
setExpanded(true);
setLoading(true);
await fetchDirectories();
setLoading(false);
} else {
setExpanded(false);
}
};
const handleClick = () => {
onSelect(basePath);
handleToggle();
};
// 计算显示的名称
const getDisplayName = () => {
if (basePath === '/') return '/';
if (/^[A-Z]:$/i.test(basePath)) return basePath;
return path.basename(basePath);
};
// 更新 Button 的 variant 逻辑
const isSeleted = selectedPath === basePath;
const variant = isSeleted
? 'solid'
: selectedPath && path.dirname(selectedPath) === basePath
? 'flat'
: 'light';
return (
<div className='ml-4'>
<Button
onPress={handleClick}
className='py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md'
size='sm'
color='primary'
variant={variant}
startContent={
<div
className={clsx(
'rounded-md',
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
)}
>
{expanded ? <IoRemove /> : <IoAdd />}
</div>
}
>
{getDisplayName()}
</Button>
{expanded && (
<div>
{loading
? (
<div className='flex py-1 px-8'>
<Spinner size='sm' color='primary' />
</div>
)
: (
dirs.map((dirName) => {
const childPath =
basePath === '/' && /^[A-Z]:$/i.test(dirName)
? dirName
: path.join(basePath, dirName);
return (
<DirectoryTree
key={childPath}
basePath={childPath}
onSelect={onSelect}
selectedPath={selectedPath}
/>
);
})
)}
</div>
)}
</div>
);
}
export default function MoveModal ({
isOpen,
moveTargetPath,
selectionInfo,
onClose,
onMove,
onSelect,
}: MoveModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<div className='rounded-md p-2 border border-default-300 overflow-auto max-h-60'>
<DirectoryTree
basePath='/'
onSelect={onSelect}
selectedPath={moveTargetPath}
/>
</div>
<p className='text-sm text-default-500 mt-2'>
{moveTargetPath || '未选择'}
</p>
<p className='text-sm text-default-500'>{selectionInfo}</p>
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
</Button>
<Button color='primary' onPress={onMove}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,44 +0,0 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@heroui/modal';
interface RenameModalProps {
isOpen: boolean
newFileName: string
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onClose: () => void
onRename: () => void
}
export default function RenameModal ({
isOpen,
newFileName,
onNameChange,
onClose,
onRename,
}: RenameModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader></ModalHeader>
<ModalBody>
<Input label='新名称' value={newFileName} onChange={onNameChange} />
</ModalBody>
<ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}>
</Button>
<Button color='primary' onPress={onRename}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -1,19 +0,0 @@
import clsx from 'clsx';
export interface IconWrapperProps {
children?: React.ReactNode
className?: string
}
const IconWrapper = ({ children, className }: IconWrapperProps) => (
<div
className={clsx(
className,
'flex items-center rounded-small justify-center w-7 h-7'
)}
>
{children}
</div>
);
export default IconWrapper;

View File

@@ -1,10 +0,0 @@
import { ChevronRightIcon } from '../icons';
const ItemCounter = ({ number }: { number: number }) => (
<div className='flex items-center gap-1 text-default-400'>
<span className='text-small'>{number}</span>
<ChevronRightIcon className='text-xl' />
</div>
);
export default ItemCounter;

View File

@@ -1,40 +0,0 @@
import { useEffect, useState } from 'react';
import { getReleaseTime } from '@/utils/time';
import type { GithubRelease as GithubReleaseType } from '@/types/github';
export interface GithubReleaseProps {
releaseData: GithubReleaseType
}
const GithubRelease: React.FC<GithubReleaseProps> = (props) => {
const { releaseData } = props;
const [releaseTime, setReleaseTime] = useState<string | null>(null);
useEffect(() => {
if (releaseData) {
const timer = setInterval(() => {
const time = getReleaseTime(releaseData.published_at);
setReleaseTime(time);
}, 1000);
return () => clearInterval(timer);
}
}, [releaseData]);
return (
<div className='flex flex-col gap-1'>
<span>Releases</span>
<div className='px-2 py-1 rounded-small bg-default-100 bg-opacity-50 backdrop-blur-sm group-data-[hover=true]:bg-default-200'>
<span className='text-tiny text-default-600'>{releaseData.name}</span>
<div className='flex gap-2 text-tiny'>
<span className='text-default-500'>{releaseTime}</span>
<span className='text-success'>Latest</span>
</div>
</div>
</div>
);
};
export default GithubRelease;

View File

@@ -1,78 +0,0 @@
import { Button } from '@heroui/button';
import { Tooltip } from '@heroui/tooltip';
import { useRequest } from 'ahooks';
import toast from 'react-hot-toast';
import { IoCopy, IoRefresh } from 'react-icons/io5';
import { request } from '@/utils/request';
import PageLoading from './page_loading';
export default function Hitokoto () {
const {
data: dataOri,
error,
loading,
run,
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
pollingInterval: 10000,
throttleWait: 1000,
});
const data = dataOri?.data;
const onCopy = () => {
try {
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`;
navigator.clipboard.writeText(text);
toast.success('复制成功');
} catch (_error) {
toast.error('复制失败, 请手动复制');
}
};
return (
<div>
<div className='relative'>
{loading && <PageLoading />}
{error
? (
<div className='text-primary-400'>{error.message}</div>
)
: (
<>
<div>{data?.hitokoto}</div>
<div className='text-right'>
<span className='text-default-400'>{data?.from}</span>{' '}
{data?.from_who}
</div>
</>
)}
</div>
<div className='flex gap-2'>
<Tooltip content='刷新' placement='top'>
<Button
onPress={run}
size='sm'
isLoading={loading}
isIconOnly
radius='full'
color='primary'
variant='flat'
>
<IoRefresh />
</Button>
</Tooltip>
<Tooltip content='复制' placement='top'>
<Button
onPress={onCopy}
size='sm'
isIconOnly
radius='full'
color='success'
variant='flat'
>
<IoCopy />
</Button>
</Tooltip>
</div>
</div>
);
}

View File

@@ -1,146 +0,0 @@
import { motion, useMotionValue, useSpring } from 'motion/react';
import { useRef, useState } from 'react';
const springValues = {
damping: 30,
stiffness: 100,
mass: 2,
};
export interface HoverTiltedCardProps {
imageSrc: string
altText?: string
captionText?: string
containerHeight?: string
containerWidth?: string
imageHeight?: string
imageWidth?: string
scaleOnHover?: number
rotateAmplitude?: number
showTooltip?: boolean
overlayContent?: React.ReactNode
displayOverlayContent?: boolean
}
export default function HoverTiltedCard ({
imageSrc,
altText = 'NapCat',
captionText = 'NapCat',
containerHeight = '200px',
containerWidth = '100%',
imageHeight = '200px',
imageWidth = '200px',
scaleOnHover = 1.1,
rotateAmplitude = 14,
showTooltip = false,
overlayContent = (
<div className='text-center mt-6 px-4 py-0.5 shadow-lg rounded-full bg-primary-600 text-default-100 bg-opacity-80'>
NapCat
</div>
),
displayOverlayContent = true,
}: HoverTiltedCardProps) {
const ref = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateX = useSpring(useMotionValue(0), springValues);
const rotateY = useSpring(useMotionValue(0), springValues);
const scale = useSpring(1, springValues);
const opacity = useSpring(0);
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1,
});
const [lastY, setLastY] = useState(0);
function handleMouse (e: React.MouseEvent) {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const offsetX = e.clientX - rect.left - rect.width / 2;
const offsetY = e.clientY - rect.top - rect.height / 2;
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude;
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude;
rotateX.set(rotationX);
rotateY.set(rotationY);
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
const velocityY = offsetY - lastY;
rotateFigcaption.set(-velocityY * 0.6);
setLastY(offsetY);
}
function handleMouseEnter () {
scale.set(scaleOnHover);
opacity.set(1);
}
function handleMouseLeave () {
opacity.set(0);
scale.set(1);
rotateX.set(0);
rotateY.set(0);
rotateFigcaption.set(0);
}
return (
<figure
ref={ref}
className='relative w-full h-full [perspective:800px] flex flex-col items-center justify-center'
style={{
height: containerHeight,
width: containerWidth,
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<motion.div
className='relative [transform-style:preserve-3d]'
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale,
}}
>
<motion.img
src={imageSrc}
alt={altText}
className='absolute top-0 left-0 object-cover rounded-md will-change-transform [transform:translateZ(0)] pointer-events-none select-none'
style={{
width: imageWidth,
height: imageHeight,
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div className='absolute top-0 left-0 right-0 z-10 flex justify-center will-change-transform [transform:translateZ(30px)]'>
{overlayContent}
</motion.div>
)}
</motion.div>
{showTooltip && (
<motion.figcaption
className='pointer-events-none absolute left-0 top-0 rounded-md bg-white px-2 py-1 text-sm text-default-900 opacity-0 z-10 hidden sm:block'
style={{
x,
y,
opacity,
rotate: rotateFigcaption,
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
);
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More