Compare commits

..

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
51fcc587d1 Add peerUid validation in AT message handler
Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:30:31 +00:00
copilot-swe-agent[bot]
374441e880 Add null/undefined parameter validation to getGroupMember
Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:28:39 +00:00
copilot-swe-agent[bot]
379b6e81fc Initial analysis - identified undefined parameter issue in getGroupMember
Co-authored-by: sj817 <74231782+sj817@users.noreply.github.com>
2025-10-02 01:27:07 +00:00
copilot-swe-agent[bot]
6ba771424b Initial plan 2025-10-02 01:23:10 +00:00
1328 changed files with 75873 additions and 54406 deletions

View File

@@ -15,7 +15,7 @@ charset = utf-8
# 4 space indentation # 4 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

2
.env.framework Normal file
View File

@@ -0,0 +1,2 @@
VITE_BUILD_TYPE = Production
VITE_BUILD_PLATFORM = Framework

2
.env.shell Normal file
View File

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

2
.env.shell-analysis Normal file
View File

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

2
.env.universal Normal file
View File

@@ -0,0 +1,2 @@
VITE_BUILD_TYPE = Production
VITE_BUILD_PLATFORM = Universal

View File

@@ -1,6 +1,6 @@
name: Bug 反馈 name: Bug 反馈
description: 报告可能的 NapCat 异常行为 description: 报告可能的 NapCat 异常行为
title: "[BUG] " title: '[BUG] '
labels: bug labels: bug
body: body:
- type: markdown - type: markdown
@@ -10,10 +10,6 @@ body:
在提交新的 Bug 反馈前,请确保您: 在提交新的 Bug 反馈前,请确保您:
* 已经搜索了现有的 issues并且没有找到可以解决您问题的方法 * 已经搜索了现有的 issues并且没有找到可以解决您问题的方法
* 不与现有的某一 issue 重复 * 不与现有的某一 issue 重复
* **不接受因发送不当内容而导致的问题报告**
- 包括但不限于:多媒体发送失败、转发消息失败、消息被拦截等因 18+ 内容、违规内容或触发风控的问题
- 提交 issue 前,请确认您发送的多媒体内容、链接、文本等均为正常合规内容,不会触发平台风控机制
- 因违规内容导致的问题,一律不予受理
- type: input - type: input
id: system-version id: system-version
attributes: attributes:
@@ -34,7 +30,7 @@ body:
id: napcat-version id: napcat-version
attributes: attributes:
label: NapCat 版本 label: NapCat 版本
description: 可在 WebUI 的「系统信息」页中找到 description: 可在 LiteLoaderQQNT 的设置页或是 QQNT 的设置页侧栏中找到
placeholder: 1.0.0 placeholder: 1.0.0
validations: validations:
required: true required: true

View File

@@ -1,60 +0,0 @@
name: Feat 请求
description: 提交新的 NapCat 功能或改进建议
title: '[FEAT] '
labels: enhancement
body:
- type: markdown
attributes:
value: |
欢迎来到 NapCat 的 Issue Tracker请填写以下表格来提交功能请求。
在提交新的功能请求前,请确保您:
* 已经搜索了现有的 issues并且没有找到类似的建议
* 不与现有的某一 issue 重复
- type: input
id: system-version
attributes:
label: 系统版本
description: 运行 QQNT 的系统版本
placeholder: Windows 11 24H2
validations:
required: true
- type: input
id: qqnt-version
attributes:
label: QQNT 版本
description: 可在 QQNT 的「关于」的设置页中找到
placeholder: 9.9.16-29927
validations:
required: true
- type: input
id: napcat-version
attributes:
label: NapCat 版本
description: 可在 WebUI 的「系统信息」页中找到
placeholder: 1.0.0
validations:
required: true
- type: textarea
id: feature-description
attributes:
label: 功能描述
description: 请详细描述你希望添加的功能或改进
validations:
required: true
- type: textarea
id: feature-reason
attributes:
label: 需求背景与理由
description: 请说明为什么需要这个功能,解决了什么问题
validations:
required: true
- type: textarea
id: feature-expected
attributes:
label: 期望的实现方式或效果
description: 请描述你期望的功能实现方式或最终效果
- type: textarea
id: other-info
attributes:
label: 其他补充信息
description: 你还想补充什么?

View File

@@ -1,27 +0,0 @@
# V?.?.?
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们为提供了的轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载
NapCat.Shell.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

@@ -1,60 +0,0 @@
注意:输出必须严格使用 NapCat 的发布说明格式,严格保证示例格式,并用简体中文。
格式规则:
1. 第一行:# V{TAG}
2. 第二行:[使用文档](https://napneko.github.io/)
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
## Windows 一键包
- 简短一句话介绍一键包用途
- 列出可下载的文件名(只列文件名,不写下载链接)
## 警告
- 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
## 如果WinX64缺少运行库或者xxx.dll
- 常见运行库建议
## 更新
按数字序列列出主要变更项,每条尽量一句话
- 前缀短 commit id例如1. 修复 get_essence_msg_list 崩溃 (a1b2c3d)
- 保持 4-18 条要点
## 开发者注意
- 列出迁移/接口断裂/配置变更;若无则省略
额外约束:
- 语言简体中文,面向最终用户
下面为真实示例,请完全参考(第一行版本号必须使用用户提供的版本号,例如 v4.9.5
# V4.9.0
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们为提供了的轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载
NapCat.Shell.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)
## 更新
1. 修改了XXXXX
2. 新增了XXXX
3. 重构了XXXX

View File

@@ -1,83 +0,0 @@
name: Auto Release Docker
on:
release:
types: [published]
jobs:
shell-docker:
runs-on: ubuntu-latest
steps:
- name: Trigger NapCat-Docker docker-publish workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
run: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
-d '{"ref":"main"}'
framework-docker:
runs-on: ubuntu-latest
steps:
- name: Trigger NapCat-Framework-Docker docker-publish workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
run: |
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCat.Docker.Framework/actions/workflows/docker-image.yml/dispatches \
-d '{"ref":"main"}'
appimage-shell-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Get Latest NapCat Version
id: get_version
run: |
# 获取当前仓库的最新 tag
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
# 输出调试信息
echo "Debug: Latest NapCat Version is ${latest_tag}"
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
- name: Trigger Release NapCat AppImage Workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_version_x86_64=${QQ_VERSION_X86_64}, qq_version_arm64=${QQ_VERSION_ARM64}"
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCatAppImageBuild/actions/workflows/release.yml/dispatches \
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_version_x86_64\":\"${QQ_VERSION_X86_64}\",\"qq_version_arm64\":\"${QQ_VERSION_ARM64}\"}}"
node-shell-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Get Latest NapCat Version
id: get_version
run: |
# 获取当前仓库的最新 tag
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
# 输出调试信息
echo "Debug: Latest NapCat Version is ${latest_tag}"
echo "latest_tag=${latest_tag}" >> $GITHUB_ENV
- name: Trigger Release NapCat AppImage Workflow
env:
GH_TOKEN: ${{ secrets.NAPCAT_BUILD }}
NAPCAT_VERSION: ${{ env.latest_tag }}
QQ_VERSION_X86_64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_x86_64.AppImage' # 写死 QQ 版本
QQ_VERSION_ARM64: 'https://dldir1v6.qq.com/qqfile/qq/QQNT/8015ff90/linuxqq_3.2.21-42086_arm64.AppImage' # 写死 QQ 版本
run: |
echo "Debug: Triggering Release NapCat AppImage with napcat_version=${NAPCAT_VERSION}, qq_url_amd64=${QQ_VERSION_X86_64}, qq_url_arm64=${QQ_VERSION_ARM64}"
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
https://api.github.com/repos/NapNeko/NapCatLinuxNodeLoader/actions/workflows/release.yml/dispatches \
-d "{\"ref\":\"main\",\"inputs\":{\"napcat_version\":\"${NAPCAT_VERSION}\",\"qq_url_amd64\":\"${QQ_VERSION_X86_64}\",\"qq_url_arm64\":\"${QQ_VERSION_ARM64}\"}}"

View File

@@ -1,14 +1,13 @@
name: Build NapCat Artifacts name: "Build Action"
on: on:
workflow_dispatch:
push: push:
branches: pull_request:
- main workflow_dispatch:
permissions: write-all permissions: write-all
jobs: jobs:
Build-Framework: Build-LiteLoader:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone Main Repository - name: Clone Main Repository
@@ -19,21 +18,14 @@ jobs:
node-version: 20.x node-version: 20.x
- name: Build NapCat.Framework - name: Build NapCat.Framework
run: | run: |
npm i -g pnpm npm i && cd napcat.webui && npm i && cd .. || exit 1
pnpm i npm run build:framework && npm run depend || exit 1
pnpm run typecheck || exit 1 rm package-lock.json
pnpm test || exit 1
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:framework
mv packages/napcat-framework/dist framework-dist
cd framework-dist
npm install --omit=dev
rm ./package-lock.json || exit 0
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: NapCat.Framework name: NapCat.Framework
path: framework-dist path: dist
Build-Shell: Build-Shell:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -45,18 +37,11 @@ jobs:
node-version: 20.x node-version: 20.x
- name: Build NapCat.Shell - name: Build NapCat.Shell
run: | run: |
npm i -g pnpm npm i && cd napcat.webui && npm i && cd .. || exit 1
pnpm i npm run build:shell && npm run depend || exit 1
pnpm run typecheck || exit 1 rm package-lock.json
pnpm test || exit 1
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:shell
mv packages/napcat-shell/dist shell-dist
cd shell-dist
npm install --omit=dev
rm ./package-lock.json || exit 0
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: NapCat.Shell name: NapCat.Shell
path: shell-dist path: dist

View File

@@ -1,274 +1,164 @@
name: Release NapCat name: "Build Release"
on: on:
workflow_dispatch:
push: push:
tags: tags:
- '*' - "v*"
permissions: write-all permissions: write-all
env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
OPENROUTER_MODEL: "kimi-k2-0905-turbo"
RELEASE_NAME: "NapCat"
jobs: jobs:
Build-Framework: check-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone Main Repository - name: Clone Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from tag
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Use Node.js 20.X - name: Use Node.js 20.X
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
- name: Build NapCat.Framework
run: |
npm i -g pnpm
pnpm i
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:framework
mv packages/napcat-framework/dist framework-dist
cd framework-dist
npm install --omit=dev
rm ./package-lock.json || exit 0
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Framework
path: framework-dist
- name: Check Version
run: |
ls
node ./script/checkVersion.cjs
sh ./checkVersion.sh
Build-LiteLoader:
needs: [check-version]
runs-on: ubuntu-latest
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
repository: 'NapNeko/NapCatQQ'
submodules: true
ref: main
token: ${{ secrets.NAPCAT_BUILD }}
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Build NuCat Framework
run: |
npm i
cd napcat.webui
npm i
cd ..
npm run build:framework
cd dist
npm i --omit=dev
cd ..
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Framework
path: dist
Build-Shell: Build-Shell:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [check-version]
steps: steps:
- name: Clone Main Repository - name: Clone Main Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Use Node.js 20.X with:
uses: actions/setup-node@v4 repository: 'NapNeko/NapCatQQ'
with: submodules: true
node-version: 20.x ref: main
- name: Build NapCat.Shell token: ${{ secrets.NAPCAT_BUILD }}
run: |
npm i -g pnpm
pnpm i
pnpm --filter napcat-webui-frontend run build || exit 1
pnpm run build:shell
mv packages/napcat-shell/dist shell-dist
cd shell-dist
npm install --omit=dev
rm ./package-lock.json || exit 0
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell
path: shell-dist
Download-QNX64:
needs: Build-Shell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download Artifacts - name: Use Node.js 20.X
uses: actions/download-artifact@v4 uses: actions/setup-node@v4
with: with:
path: ./artifacts node-version: 20.x
- name: Setup tools - name: Build NuCat Shell
run: | run: |
sudo apt update npm i
sudo apt install -y aria2 unzip zip p7zip-full curl jq cd napcat.webui
npm i
cd ..
npm run build:shell
cd dist
npm i --omit=dev
cd ..
- name: Download QQ x64, Node.js and Assemble NapCat.Shell.Windows.Node.zip - name: Upload Artifact
run: | uses: actions/upload-artifact@v4
set -euo pipefail with:
TMPDIR=$(mktemp -d) name: NapCat.Shell
cd "$TMPDIR" path: dist
# -----------------------------
# 1) 下载 QQ x64
# -----------------------------
# JS_URL="https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
# JS_URL="https://slave.docadan488.workers.dev/proxy?url=https://cdn-go.cn/qq-web/im.qq.com_new/latest/rainbow/windowsConfig.js"
# NT_URL=$(curl -fsSL "$JS_URL" | grep -oP '"ntDownloadX64Url"\s*:\s*"\K[^"]+')
NT_URL="https://dldir1v6.qq.com/qqfile/qq/QQNT/eb263b35/QQ9.9.23.42086_x64.exe"
QQ_ZIP="$(basename "$NT_URL")"
aria2c -x16 -s16 -k1M -o "$QQ_ZIP" "$NT_URL"
QQ_EXTRACT="$TMPDIR/qq_extracted"
mkdir -p "$QQ_EXTRACT"
7z x -y -o"$QQ_EXTRACT" "$QQ_ZIP" >/dev/null
# -----------------------------
# 2) 下载 Node.js Windows x64 zip 22.11.0
# -----------------------------
NODE_VER="22.11.0"
NODE_URL="https://nodejs.org/dist/v$NODE_VER/node-v$NODE_VER-win-x64.zip"
NODE_ZIP="node-v$NODE_VER-win-x64.zip"
aria2c -x1 -s1 -k1M -o "$NODE_ZIP" "$NODE_URL"
NODE_EXTRACT="$TMPDIR/node_extracted"
mkdir -p "$NODE_EXTRACT"
unzip -q "$NODE_ZIP" -d "$NODE_EXTRACT"
# -----------------------------
# 3) 创建输出目录
# -----------------------------
OUT_DIR="$GITHUB_WORKSPACE/NapCat.Shell.Windows.Node"
mkdir -p "$OUT_DIR/NapCat.Shell.Windows.Node"
# -----------------------------
# 4) 解压 NapCat.Shell.zip 到 napcat
# -----------------------------
cp -a "$GITHUB_WORKSPACE/artifacts/NapCat.Shell/." "$OUT_DIR/napcat/"
# -----------------------------
# 5) 拷贝 QQ 文件到 NapCat.Shell.Windows.Node
# -----------------------------
QQ_TARGETS=("avif_convert.dll" "broadcast_ipc.dll" "config.json" "libglib-2.0-0.dll" "libgobject-2.0-0.dll" "libvips-42.dll" "ncnn.dll" "opencv.dll" "package.json" "QBar.dll" "wrapper.node")
for name in "${QQ_TARGETS[@]}"; do
find "$QQ_EXTRACT" -iname "$name" -exec cp -a {} "$OUT_DIR" \; || true
done
# -----------------------------
# 6) 拷贝仓库文件 napcat.bat 和 index.js
# -----------------------------
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/napcat.bat" "$OUT_DIR/" || true
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/index.js" "$OUT_DIR/" || true
cp -a "$GITHUB_WORKSPACE/packages/napcat-develop/QQNT.dll" "$OUT_DIR/" || true
# -----------------------------
# 7) 拷贝 Node.exe 到 NapCat.Shell.Windows.Node
# -----------------------------
cp -a "$NODE_EXTRACT/node-v$NODE_VER-win-x64/node.exe" "$OUT_DIR/" || true
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: NapCat.Shell.Windows.Node
path: NapCat.Shell.Windows.Node
release-napcat: release-napcat:
needs: [Build-Framework, Build-Shell, Download-QNX64] needs: [Build-LiteLoader,Build-Shell]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download Artifacts - name: Clone Main Repository
uses: actions/download-artifact@v4 uses: actions/checkout@v4
with: with:
path: ./artifacts repository: 'NapNeko/NapCatQQ'
submodules: true
ref: main
token: ${{ secrets.NAPCAT_BUILD }}
- name: Zip Artifacts - name: Download All Artifact
uses: actions/download-artifact@v4
- name: Compress subdirectories
run: |
cd ./NapCat.Shell/
zip -q -r NapCat.Shell.zip *
cd ..
cd ./NapCat.Framework/
zip -q -r NapCat.Framework.zip *
cd ..
rm ./NapCat.Shell.zip -rf
rm ./NapCat.Framework.zip -rf
mv ./NapCat.Shell/NapCat.Shell.zip ./
mv ./NapCat.Framework/NapCat.Framework.zip ./
mkdir ./NapCat.Framework.Windows.Once
unzip -q ./external/LiteLoaderWrapper.zip -d ./NapCat.Framework.Windows.Once
cd ./NapCat.Framework.Windows.Once
ls
mkdir -p ./LL/plugins/NapCatQQ
unzip -q ../NapCat.Framework.zip -d ./LL/plugins/NapCatQQ
zip -q -r NapCat.Framework.Windows.Once.zip *
cd ..
mv ./NapCat.Framework.Windows.Once/NapCat.Framework.Windows.Once.zip ./
- name: Extract version from tag
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Clone Changes Log
run: curl -o CHANGELOG.md https://fastly.jsdelivr.net/gh/NapNeko/NapCatQQ@main/docs/changelogs/CHANGELOG.v${{ env.VERSION }}.md
- name: Create Release Draft and Upload Artifacts
uses: softprops/action-gh-release@v1
with:
name: NapCat V${{ env.VERSION }}
token: ${{ secrets.GITHUB_TOKEN }}
body_path: CHANGELOG.md
files: |
NapCat.Framework.zip
NapCat.Shell.zip
NapCat.Framework.Windows.Once.zip
draft: true
build-docker:
needs: release-napcat
runs-on: ubuntu-latest
steps:
- name: Dispatch Docker Build
run: | run: |
cd artifacts curl -X POST \
[ -d NapCat.Framework ] && (cd NapCat.Framework && zip -qr ../../NapCat.Framework.zip .) -H "Authorization: Bearer ${{ secrets.NAPCAT_BUILD }}" \
[ -d NapCat.Shell ] && (cd NapCat.Shell && zip -qr ../../NapCat.Shell.zip .) -H "Accept: application/vnd.github.v3+json" \
[ -d NapCat.Shell.Windows.Node ] && (cd NapCat.Shell.Windows.Node && zip -qr ../../NapCat.Shell.Windows.Node.zip .) https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
cd .. -d '{"ref": "main"}'
- name: Generate release note via OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner
GITHUB_REPO: "NapCatQQ" # 替换成你的 repo 名
run: |
set -euo pipefail
# 当前 tag
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
echo "Current tag: $CURRENT_TAG"
# 从 GitHub API 获取 tag 列表
TAGS_JSON=$(curl -s "https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/tags?per_page=100")
TAGS=( $(echo "$TAGS_JSON" | jq -r '.[].name' | sort -V) )
# 找到上一个 tag
PREV_TAG=""
for i in "${!TAGS[@]}"; do
if [ "${TAGS[$i]}" = "$CURRENT_TAG" ]; then
if [ $i -gt 0 ]; then
PREV_TAG="${TAGS[$((i-1))]}"
fi
break
fi
done
if [ -z "$PREV_TAG" ]; then
echo "❌ Could not find previous tag for $CURRENT_TAG, aborting."
exit 1
fi
echo "Previous tag: $PREV_TAG"
# 强制拉取上一个 tag 和当前 tag
git fetch origin "refs/tags/$PREV_TAG:refs/tags/$PREV_TAG" --force
git fetch origin "refs/tags/$CURRENT_TAG:refs/tags/$CURRENT_TAG" --force
# 获取 commit title + body + 作者,保留换行
COMMITS=$(git log --pretty=format:'%h %B (%an)' "$PREV_TAG".."$CURRENT_TAG" | sed 's/$/\\n/')
echo "Commit list from $PREV_TAG to $CURRENT_TAG:"
echo -e "$COMMITS"
# 读取 prompt
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
SYSTEM_PROMPT=$(<"$PROMPT_FILE")
# 构建用户内容
USER_CONTENT="当前真正的版本: $CURRENT_TAG\n提交列表:\n$COMMITS"
# 构建请求 JSON
BODY=$(jq -n \
--arg system "$SYSTEM_PROMPT" \
--arg user "$USER_CONTENT" \
'{model: env.OPENROUTER_MODEL, messages:[{role:"system", content:$system},{role:"user", content:$user}], temperature:0.3, max_tokens:800}')
echo "=== OpenRouter request body ==="
echo "$BODY" | jq .
# 调用 OpenRouter
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$BODY"); then
echo "=== raw response ==="
echo "$RESPONSE"
echo "=== OpenRouter raw response ==="
if echo "$RESPONSE" | jq . >/dev/null 2>&1; then
echo "$RESPONSE" | jq .
else
echo "jq failed to parse response"
fi
# 提取生成内容
RELEASE_BODY=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // .choices[0].text // ""' 2>/dev/null || echo "")
if [ -z "$RELEASE_BODY" ]; then
echo "❌ OpenRouter failed to generate release note, using default.md"
cp .github/prompt/default.md CHANGELOG.md
else
echo -e "$RELEASE_BODY" > CHANGELOG.md
fi
else
echo "❌ Curl failed, using default.md"
cp .github/prompt/default.md CHANGELOG.md
fi
echo "=== generated release note ==="
cat CHANGELOG.md
- name: Create Release Draft and Upload Artifacts
uses: softprops/action-gh-release@v1
with:
name: NapCat ${{ github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
body_path: CHANGELOG.md
files: |
NapCat.Shell.Windows.Node.zip
NapCat.Framework.zip
NapCat.Shell.zip
draft: true

3
.gitignore vendored
View File

@@ -14,6 +14,3 @@ devconfig/*
*.db *.db
checkVersion.sh checkVersion.sh
bun.lockb bun.lockb
tests/run/
guild1.db-wal
guild1.db-shm

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"
}

111
.vscode/launch.json vendored
View File

@@ -2,11 +2,114 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"type": "node-terminal", "type": "node",
"request": "launch", "request": "launch",
"name": "调试程序", "name": "dev:shell",
"command": "pnpm run dev:shell", "runtimeExecutable": "npm",
"cwd": "${workspaceFolder}" "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"
]
} }
] ]
} }

50
.vscode/settings.json vendored
View File

@@ -1,37 +1,17 @@
{ {
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false, "explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
".env.universal": ".env.*", ".env.universal": ".env.*",
"vite.config.ts": "vite*.ts", "tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
"README.md": "CODE_OF_CONDUCT.md, RELEASES.md, CONTRIBUTING.md, CHANGELOG.md, SECURITY.md", "package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
"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"
"css.customData": [ ],
".vscode/tailwindcss.json" "editor.formatOnPaste": false,
], "editor.formatOnSave": false,
"editor.detectIndentation": false, "editor.codeActionsOnSave": {
"editor.tabSize": 2, "source.fixAll.eslint": "never"
"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

@@ -3,6 +3,8 @@
# NapCat # NapCat
_Modern protocol-side framework implemented based on NTQQ._ _Modern protocol-side framework implemented based on NTQQ._
> 云起兮风生,心向远方兮路未曾至. > 云起兮风生,心向远方兮路未曾至.
@@ -12,7 +14,6 @@ _Modern protocol-side framework implemented based on NTQQ._
--- ---
## New Feature ## New Feature
在 v4.8.115+ 版本开始 在 v4.8.115+ 版本开始
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file) 1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
@@ -22,21 +23,19 @@ _Modern protocol-side framework implemented based on NTQQ._
- [2] 采用字符串可以解决扩展到int64的问题同时也可以解决部分语言如JavaScript对大整数支持不佳的问题增加极少成本。 - [2] 采用字符串可以解决扩展到int64的问题同时也可以解决部分语言如JavaScript对大整数支持不佳的问题增加极少成本。
## Welcome ## Welcome
+ NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
- NapCatQQ is a modern implementation of the Bot protocol based on NTQQ.
- NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现 - NapCatQQ 是现代化的基于 NTQQ 的 Bot 协议端实现
## Feature ## Feature
- **Easy to Use** + **Easy to Use**
- 作为初学者能够轻松使用. - 作为初学者能够轻松使用.
- **Quick and Efficient** + **Quick and Efficient**
- 在低内存操作系统长时运行. - 在低内存操作系统长时运行.
- **Rich API Interface** + **Rich API Interface**
- 完整实现了大部分标准接口. - 完整实现了大部分标准接口.
- **Stable and Reliable** + **Stable and Reliable**
- 持续稳定的开发与维护. - 持续稳定的开发与维护.
## Quick Start ## Quick Start
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本 可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
@@ -44,7 +43,6 @@ _Modern protocol-side framework implemented based on NTQQ._
**首次使用**请务必查看如下文档看使用教程 **首次使用**请务必查看如下文档看使用教程
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。 > 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
## Link ## Link
| 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/) | | 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/) |
@@ -66,22 +64,20 @@ _Modern protocol-side framework implemented based on NTQQ._
## Thanks ## Thanks
- [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权 + [Lagrange](https://github.com/LagrangeDev/Lagrange.Core) 对本项目的大力支持 参考部分代码 已获授权
- [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下 + [AstrBot](https://github.com/AstrBotDevs/AstrBot) 是完美适配本项目的LLM Bot框架 在此推荐一下
- [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下 + [MaiBot](https://github.com/MaiM-with-u/MaiBot) 一只赛博群友 麦麦 Bot框架 在此推荐一下
- [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下 + [qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter/) 基于NapCat的消息导出工具 在此推荐一下
- 不过最最重要的 还是需要感谢屏幕前的你哦~ + 不过最最重要的 还是需要感谢屏幕前的你哦~
--- ---
## License ## License
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点: 本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
1. 第三方库代码或修改部分遵循其原始开源许可. 1. 第三方库代码或修改部分遵循其原始开源许可.
2. 本项目获取部分项目授权而不受部分约束 2. 本项目获取部分项目授权而不受部分约束
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE). 2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).

View File

@@ -1,52 +0,0 @@
import neostandard from 'neostandard';
/** 尾随逗号 */
const commaDangle = val => {
if (val?.rules?.['@stylistic/comma-dangle']?.[0] === 'warn') {
const rule = val?.rules?.['@stylistic/comma-dangle']?.[1];
Object.keys(rule).forEach(key => {
rule[key] = 'always-multiline';
});
val.rules['@stylistic/comma-dangle'][1] = rule;
}
/** 三元表达式 */
if (val?.rules?.['@stylistic/indent']) {
val.rules['@stylistic/indent'][2] = {
...val.rules?.['@stylistic/indent']?.[2],
flatTernaryExpressions: true,
offsetTernaryExpressions: false,
};
}
/** 支持下划线 - 禁用 camelcase 规则 */
if (val?.rules?.camelcase) {
val.rules.camelcase = 'off';
}
/** 未使用的变量强制报错 */
if (val?.rules?.['@typescript-eslint/no-unused-vars']) {
val.rules['@typescript-eslint/no-unused-vars'] = ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}];
}
return val;
};
/** 忽略的文件 */
const ignores = [
'node_modules',
'**/dist/**',
'launcher',
];
const options = neostandard({
ts: true,
ignores,
semi: true, // 强制使用分号
}).map(commaDangle);
export default options;

32
eslint.config.mjs Normal file
View File

@@ -0,0 +1,32 @@
import eslint from '@eslint/js';
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
import tsEslintParser from '@typescript-eslint/parser';
import globals from "globals";
const customTsFlatConfig = [
{
name: 'typescript-eslint/base',
languageOptions: {
parser: tsEslintParser,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
NodeJS: 'readonly', // 添加 NodeJS 全局变量
},
},
files: ['**/*.{ts,tsx}'],
rules: {
...tsEslintPlugin.configs.recommended.rules,
'quotes': ['error', 'single'], // 使用单引号
'semi': ['error', 'always'], // 强制使用分号
'indent': ['error', 4], // 使用 4 空格缩进
},
plugins: {
'@typescript-eslint': tsEslintPlugin,
},
ignores: ['src/webui/**'], // 忽略 src/webui/ 目录所有文件
},
];
export default [eslint.configs.recommended, ...customTsFlatConfig];

BIN
external/LiteLoaderWrapper.zip vendored Normal file

Binary file not shown.

BIN
external/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

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,7 +16,7 @@ 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

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,7 +16,7 @@ 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

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,7 +25,7 @@ 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

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,7 +25,7 @@ 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

View File

@@ -1,5 +1,5 @@
const path = require('path'); const path = require('path');
const CurrentPath = path.dirname(__filename); const CurrentPath = path.dirname(__filename);
(async () => { (async () => {
await import('file://' + path.join(CurrentPath, './napcat/napcat.mjs')); await import("file://" + path.join(CurrentPath, './napcat/napcat.mjs'));
})(); })();

View File

@@ -1,9 +1,9 @@
{ {
"name": "qq-chat", "name": "qq-chat",
"verHash": "2c9d3f6c", "verHash": "cc326038",
"version": "9.9.22-40990", "version": "9.9.21-39038",
"linuxVersion": "3.2.20-40990", "linuxVersion": "3.2.19-39038",
"linuxVerHash": "ec800879", "linuxVerHash": "c773cdf7",
"private": true, "private": true,
"description": "QQ", "description": "QQ",
"productName": "QQ", "productName": "QQ",
@@ -17,7 +17,7 @@
"qd": "externals/devtools/cli/index.js" "qd": "externals/devtools/cli/index.js"
}, },
"main": "./loadNapCat.js", "main": "./loadNapCat.js",
"buildVersion": "40990", "buildVersion": "39038",
"isPureShell": true, "isPureShell": true,
"isByteCodeShell": true, "isByteCodeShell": true,
"platform": "win32", "platform": "win32",

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 684 KiB

View File

@@ -4,7 +4,7 @@
"name": "NapCatQQ", "name": "NapCatQQ",
"slug": "NapCat.Framework", "slug": "NapCat.Framework",
"description": "高性能的 OneBot 11 协议实现", "description": "高性能的 OneBot 11 协议实现",
"version": "0.0.1", "version": "4.8.116",
"icon": "./logo.png", "icon": "./logo.png",
"authors": [ "authors": [
{ {

View File

@@ -0,0 +1,7 @@
dist
*.md
*.html
yarn.lock
package-lock.json
node_modules
pnpm-lock.yaml

23
napcat.webui/.prettierrc Normal file
View File

@@ -0,0 +1,23 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"semi": false,
"trailingComma": "none",
"bracketSpacing": true,
"importOrder": [
"<THIRD_PARTY_MODULES>",
"^@/const/(.*)$",
"^@/store/(.*)$",
"^@/components/(.*)$",
"^@/contexts/(.*)$",
"^@/hooks/(.*)$",
"^@/utils/(.*)$",
"^@/(.*)$",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": ["@trivago/prettier-plugin-sort-imports"]
}

View File

@@ -0,0 +1,91 @@
import eslint_js from '@eslint/js'
import tsEslintPlugin from '@typescript-eslint/eslint-plugin'
import tsEslintParser from '@typescript-eslint/parser'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import reactPlugin from 'eslint-plugin-react'
import reactHooksPlugin from 'eslint-plugin-react-hooks'
import globals from 'globals'
const customTsFlatConfig = [
{
name: 'typescript-eslint/base',
languageOptions: {
parser: tsEslintParser,
sourceType: 'module'
},
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'],
rules: {
...tsEslintPlugin.configs.recommended.rules
},
plugins: {
'@typescript-eslint': tsEslintPlugin
}
}
]
export default [
eslint_js.configs.recommended,
eslintPluginPrettierRecommended,
...customTsFlatConfig,
{
name: 'global config',
languageOptions: {
globals: {
...globals.es2022,
...globals.browser,
...globals.node
},
parserOptions: {
warnOnUnsupportedTypeScriptVersion: false
}
},
rules: {
'prettier/prettier': 'error',
'no-unused-vars': 'off',
'no-undef': 'off',
//关闭不能再promise中使用ansyc
'no-async-promise-executor': 'off',
//关闭不能再常量中使用??
'no-constant-binary-expression': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-unused-vars': 'off',
//禁止失去精度的字面数字
'@typescript-eslint/no-loss-of-precision': 'off',
//禁止使用any
'@typescript-eslint/no-explicit-any': 'error'
}
},
{
ignores: ['**/node_modules', '**/dist', '**/output']
},
{
name: 'react-eslint',
files: ['src/*.{js,jsx,mjs,cjs,ts,tsx}'],
plugins: {
react: reactPlugin,
'react-hooks': reactHooksPlugin
},
languageOptions: {
...reactPlugin.configs.recommended.languageOptions
},
rules: {
...reactPlugin.configs.recommended.rules,
'react/react-in-jsx-scope': 'off'
},
settings: {
react: {
// 需要显示安装 react
version: 'detect'
}
}
},
{
languageOptions: { globals: { ...globals.browser, ...globals.node } }
},
eslintConfigPrettier
]

15995
napcat.webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,11 @@
{ {
"name": "napcat-webui-frontend", "name": "napcat-webui",
"private": true, "private": true,
"version": "0.0.6", "version": "0.0.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host=0.0.0.0", "dev": "vite --host=0.0.0.0",
"build": "tsc && vite build", "build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix", "lint": "eslint -c eslint.config.mjs ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview" "preview": "vite preview"
}, },
@@ -48,7 +47,6 @@
"@monaco-editor/react": "4.7.0-rc.0", "@monaco-editor/react": "4.7.0-rc.0",
"@react-aria/visually-hidden": "^3.8.19", "@react-aria/visually-hidden": "^3.8.19",
"@reduxjs/toolkit": "^2.5.1", "@reduxjs/toolkit": "^2.5.1",
"@simplewebauthn/browser": "^13.2.2",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
@@ -88,6 +86,7 @@
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0",
"@react-types/shared": "^3.26.0", "@react-types/shared": "^3.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
@@ -98,14 +97,20 @@
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.19.0", "eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.3", "eslint-plugin-prettier": "5.2.3",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.14.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

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

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

90
napcat.webui/src/App.tsx Normal file
View File

@@ -0,0 +1,90 @@
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

View File

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

View File

Before

Width:  |  Height:  |  Size: 684 KiB

After

Width:  |  Height:  |  Size: 684 KiB

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button'
import clsx from 'clsx'; import clsx from 'clsx'
import toast from 'react-hot-toast'; import toast from 'react-hot-toast'
import { IoMdRefresh } from 'react-icons/io'; import { IoMdRefresh } from 'react-icons/io'
export interface SaveButtonsProps { export interface SaveButtonsProps {
onSubmit: () => void onSubmit: () => void
@@ -16,7 +16,7 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
reset, reset,
isSubmitting, isSubmitting,
refresh, refresh,
className, className
}) => ( }) => (
<div <div
className={clsx( className={clsx(
@@ -24,18 +24,18 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
className className
)} )}
> >
<div className='flex items-center justify-center gap-2 mt-5'> <div className="flex items-center justify-center gap-2 mt-5">
<Button <Button
color='default' color="default"
onPress={() => { onPress={() => {
reset(); reset()
toast.success('重置成功'); toast.success('重置成功')
}} }}
> >
</Button> </Button>
<Button <Button
color='primary' color="primary"
isLoading={isSubmitting} isLoading={isSubmitting}
onPress={() => onSubmit()} onPress={() => onSubmit()}
> >
@@ -44,9 +44,9 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
{refresh && ( {refresh && (
<Button <Button
isIconOnly isIconOnly
color='secondary' color="secondary"
radius='full' radius="full"
variant='flat' variant="flat"
onPress={() => refresh()} onPress={() => refresh()}
> >
<IoMdRefresh size={24} /> <IoMdRefresh size={24} />
@@ -54,6 +54,6 @@ const SaveButtons: React.FC<SaveButtonsProps> = ({
)} )}
</div> </div>
</div> </div>
); )
export default SaveButtons; export default SaveButtons

View File

@@ -0,0 +1,254 @@
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: 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>
<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

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

View File

@@ -0,0 +1,125 @@
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: 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

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

View File

@@ -0,0 +1,58 @@
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

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

View File

@@ -0,0 +1,126 @@
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: 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

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,30 @@
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) {
let 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,4 +1,4 @@
import Quill from 'quill'; import Quill from 'quill'
// eslint-disable-next-line // eslint-disable-next-line
const BlockEmbed = Quill.import('blots/block/embed') as any const BlockEmbed = Quill.import('blots/block/embed') as any
@@ -6,38 +6,38 @@ export interface ReplyBlockValue {
messageId: string messageId: string
} }
class ReplyBlock extends BlockEmbed { class ReplyBlock extends BlockEmbed {
static blotName = 'reply'; static blotName = 'reply'
static tagName = 'div'; static tagName = 'div'
static classNames = [ static classNames = [
'p-2', 'p-2',
'select-none', 'select-none',
'bg-default-100', 'bg-default-100',
'rounded-md', 'rounded-md',
'pointer-events-none', 'pointer-events-none'
]; ]
static create (value: ReplyBlockValue) { static create(value: ReplyBlockValue) {
const node = super.create(); const node = super.create()
node.setAttribute('data-message-id', value.messageId); node.setAttribute('data-message-id', value.messageId)
node.setAttribute('contenteditable', 'false'); node.setAttribute('contenteditable', 'false')
node.classList.add(...ReplyBlock.classNames); node.classList.add(...ReplyBlock.classNames)
const innerDom = document.createElement('div'); const innerDom = document.createElement('div')
innerDom.classList.add('text-sm', 'text-default-500', 'relative'); innerDom.classList.add('text-sm', 'text-default-500', 'relative')
const svgContainer = document.createElement('div'); const svgContainer = document.createElement('div')
svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0'); 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>'; 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; svgContainer.innerHTML = svg
innerDom.innerHTML = `消息ID${value.messageId}`; innerDom.innerHTML = `消息ID${value.messageId}`
innerDom.appendChild(svgContainer); innerDom.appendChild(svgContainer)
node.appendChild(innerDom); node.appendChild(innerDom)
return node; return node
} }
static value (node: HTMLElement): ReplyBlockValue { static value(node: HTMLElement): ReplyBlockValue {
return { return {
messageId: node.getAttribute('data-message-id') || '', messageId: node.getAttribute('data-message-id') || ''
}; }
} }
} }
export default ReplyBlock; export default ReplyBlock

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'; import { Card, CardBody, CardFooter, CardHeader } from '@heroui/card'
import clsx from 'clsx'; import clsx from 'clsx'
import { title } from '../primitives'; import { title } from '../primitives'
export interface ContainerProps { export interface ContainerProps {
title: string title: string
@@ -24,13 +24,13 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
action, action,
tag, tag,
enableSwitch, enableSwitch,
children, children
}) => { }) => {
return ( return (
<Card className='bg-opacity-50 backdrop-blur-sm'> <Card className="bg-opacity-50 backdrop-blur-sm">
<CardHeader className='pb-0 flex items-center'> <CardHeader className={'pb-0 flex items-center'}>
{tag && ( {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'> <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} {tag}
</div> </div>
)} )}
@@ -39,19 +39,19 @@ const DisplayCardContainer: React.FC<ContainerProps> = ({
title({ title({
color: 'foreground', color: 'foreground',
size: 'xs', size: 'xs',
shadow: true, shadow: true
}), }),
'truncate' 'truncate'
)} )}
> >
{_title} {_title}
</h2> </h2>
<div className='ml-auto'>{enableSwitch}</div> <div className="ml-auto">{enableSwitch}</div>
</CardHeader> </CardHeader>
<CardBody className='text-sm'>{children}</CardBody> <CardBody className="text-sm">{children}</CardBody>
<CardFooter>{action}</CardFooter> <CardFooter>{action}</CardFooter>
</Card> </Card>
); )
}; }
export default DisplayCardContainer; export default DisplayCardContainer

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip'
import NetworkDisplayCard from './common_card'; import NetworkDisplayCard from './common_card'
import type { NetworkDisplayCardFields } from './common_card'; import type { NetworkDisplayCardFields } from './common_card'
interface WebsocketClientDisplayCardProps { interface WebsocketClientDisplayCardProps {
data: OneBotConfig['network']['websocketClients'][0] data: OneBotConfig['network']['websocketClients'][0]
@@ -15,14 +15,14 @@ interface WebsocketClientDisplayCardProps {
const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = ( const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
props props
) => { ) => {
const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props; const { data, showType, onEdit, onEnable, onDelete, onEnableDebug } = props
const { const {
url, url,
heartInterval, heartInterval,
reconnectInterval, reconnectInterval,
messagePostFormat, messagePostFormat,
reportSelfMessage, reportSelfMessage
} = data; } = data
const fields: NetworkDisplayCardFields<'websocketClients'> = [ const fields: NetworkDisplayCardFields<'websocketClients'> = [
{ label: 'URL', value: url }, { label: 'URL', value: url },
@@ -33,25 +33,25 @@ const WebsocketClientDisplayCard: React.FC<WebsocketClientDisplayCardProps> = (
label: '上报自身消息', label: '上报自身消息',
value: reportSelfMessage, value: reportSelfMessage,
render: (value) => ( render: (value) => (
<Chip color={value ? 'success' : 'default'} size='sm' variant='flat'> <Chip color={value ? 'success' : 'default'} size="sm" variant="flat">
{value ? '是' : '否'} {value ? '是' : '否'}
</Chip> </Chip>
), )
}, }
]; ]
return ( return (
<NetworkDisplayCard <NetworkDisplayCard
data={data} data={data}
showType={showType} showType={showType}
typeLabel='Websocket客户端' typeLabel="Websocket客户端"
fields={fields} fields={fields}
onEdit={onEdit} onEdit={onEdit}
onEnable={onEnable} onEnable={onEnable}
onDelete={onDelete} onDelete={onDelete}
onEnableDebug={onEnableDebug} onEnableDebug={onEnableDebug}
/> />
); )
}; }
export default WebsocketClientDisplayCard; export default WebsocketClientDisplayCard

View File

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

View File

@@ -1,7 +1,7 @@
import { Card, CardBody } from '@heroui/card'; import { Card, CardBody } from '@heroui/card'
import clsx from 'clsx'; import clsx from 'clsx'
import { title } from '@/components/primitives'; import { title } from '@/components/primitives'
export interface NetworkItemDisplayProps { export interface NetworkItemDisplayProps {
count: number count: number
@@ -12,7 +12,7 @@ export interface NetworkItemDisplayProps {
const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
count, count,
label, label,
size = 'md', size = 'md'
}) => { }) => {
return ( return (
<Card <Card
@@ -22,16 +22,16 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
? 'col-span-8 md:col-span-2 bg-primary-50 shadow-primary-100' ? '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' : 'col-span-2 md:col-span-1 bg-warning-100 shadow-warning-200'
)} )}
shadow='sm' shadow="sm"
> >
<CardBody className='items-center md:gap-1 p-1 md:p-2'> <CardBody className="items-center md:gap-1 p-1 md:p-2">
<div <div
className={clsx( className={clsx(
'flex-1', 'flex-1',
size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl', size === 'md' ? 'text-2xl md:text-3xl' : 'text-xl md:text-2xl',
title({ title({
color: size === 'md' ? 'pink' : 'yellow', color: size === 'md' ? 'pink' : 'yellow',
size, size
}) })
)} )}
> >
@@ -44,7 +44,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
title({ title({
color: size === 'md' ? 'pink' : 'yellow', color: size === 'md' ? 'pink' : 'yellow',
shadow: true, shadow: true,
size: 'xxs', size: 'xxs'
}) })
)} )}
> >
@@ -52,7 +52,7 @@ const NetworkItemDisplay: React.FC<NetworkItemDisplayProps> = ({
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
); )
}; }
export default NetworkItemDisplay; export default NetworkItemDisplay

View File

@@ -1,6 +1,6 @@
import { Card, CardProps } from '@heroui/card'; import { Card, CardProps } from '@heroui/card'
import clsx from 'clsx'; import clsx from 'clsx'
import React from 'react'; import React from 'react'
export interface HoverEffectCardProps extends CardProps { export interface HoverEffectCardProps extends CardProps {
children: React.ReactNode children: React.ReactNode
@@ -18,15 +18,15 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
className, className,
style, style,
lightClassName, lightClassName,
lightStyle, lightStyle
} = props; } = props
const cardRef = React.useRef<HTMLDivElement | null>(null); const cardRef = React.useRef<HTMLDivElement | null>(null)
const lightRef = React.useRef<HTMLDivElement | null>(null); const lightRef = React.useRef<HTMLDivElement | null>(null)
const [isShowLight, setIsShowLight] = React.useState(false); const [isShowLight, setIsShowLight] = React.useState(false)
const [pos, setPos] = React.useState({ const [pos, setPos] = React.useState({
left: 0, left: 0,
top: 0, top: 0
}); })
return ( return (
<Card <Card
@@ -40,53 +40,53 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
willChange: 'transform', willChange: 'transform',
transform: transform:
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)', 'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)',
...style, ...style
}} }}
onMouseEnter={() => { onMouseEnter={() => {
if (cardRef.current) { if (cardRef.current) {
cardRef.current.style.transition = 'transform 0.3s ease-out'; cardRef.current.style.transition = 'transform 0.3s ease-out'
} }
}} }}
onMouseLeave={() => { onMouseLeave={() => {
setIsShowLight(false); setIsShowLight(false)
if (cardRef.current) { if (cardRef.current) {
cardRef.current.style.transition = 'transform 0.5s'; cardRef.current.style.transition = 'transform 0.5s'
cardRef.current.style.transform = cardRef.current.style.transform =
'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'; 'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)'
} }
}} }}
onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => { onMouseMove={(e: React.MouseEvent<HTMLDivElement>) => {
if (cardRef.current) { if (cardRef.current) {
setIsShowLight(true); setIsShowLight(true)
const { x, y } = cardRef.current.getBoundingClientRect(); const { x, y } = cardRef.current.getBoundingClientRect()
const { clientX, clientY } = e; const { clientX, clientY } = e
const offsetX = clientX - x; const offsetX = clientX - x
const offsetY = clientY - y; const offsetY = clientY - y
const lightWidth = lightStyle?.width?.toString() || '100'; const lightWidth = lightStyle?.width?.toString() || '100'
const lightHeight = lightStyle?.height?.toString() || '100'; const lightHeight = lightStyle?.height?.toString() || '100'
const lightWidthNum = parseInt(lightWidth); const lightWidthNum = parseInt(lightWidth)
const lightHeightNum = parseInt(lightHeight); const lightHeightNum = parseInt(lightHeight)
const left = offsetX - lightWidthNum / 2; const left = offsetX - lightWidthNum / 2
const top = offsetY - lightHeightNum / 2; const top = offsetY - lightHeightNum / 2
setPos({ setPos({
left, left,
top, top
}); })
cardRef.current.style.transition = 'transform 0.1s'; cardRef.current.style.transition = 'transform 0.1s'
const rangeX = 400 / 2; const rangeX = 400 / 2
const rangeY = 400 / 2; const rangeY = 400 / 2
const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation; const rotateX = ((offsetY - rangeY) / rangeY) * maxXRotation
const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation; const rotateY = -1 * ((offsetX - rangeX) / rangeX) * maxYRotation
cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; cardRef.current.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`
} }
}} }}
> >
@@ -98,12 +98,12 @@ const HoverEffectCard: React.FC<HoverEffectCardProps> = (props) => {
lightClassName lightClassName
)} )}
style={{ style={{
...pos, ...pos
}} }}
/> />
{children} {children}
</Card> </Card>
); )
}; }
export default HoverEffectCard; export default HoverEffectCard

View File

@@ -0,0 +1,30 @@
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

@@ -11,8 +11,8 @@ import {
FaFileVideo, FaFileVideo,
FaFileWord, FaFileWord,
FaFileZipper, FaFileZipper,
FaFolderClosed, FaFolderClosed
} from 'react-icons/fa6'; } from 'react-icons/fa6'
export interface FileIconProps { export interface FileIconProps {
name?: string name?: string
@@ -20,12 +20,12 @@ export interface FileIconProps {
} }
const FileIcon = (props: FileIconProps) => { const FileIcon = (props: FileIconProps) => {
const { name, isDirectory = false } = props; const { name, isDirectory = false } = props
if (isDirectory) { if (isDirectory) {
return <FaFolderClosed className='text-yellow-500' />; return <FaFolderClosed className="text-yellow-500" />
} }
const ext = name?.split('.').pop() || ''; const ext = name?.split('.').pop() || ''
if (ext) { if (ext) {
switch (ext.toLowerCase()) { switch (ext.toLowerCase()) {
case 'jpg': case 'jpg':
@@ -50,20 +50,20 @@ const FileIcon = (props: FileIconProps) => {
case 'fig': case 'fig':
case 'xd': case 'xd':
case 'svgz': case 'svgz':
return <FaFileImage className='text-green-500' />; return <FaFileImage className="text-green-500" />
case 'pdf': case 'pdf':
return <FaFilePdf className='text-red-500' />; return <FaFilePdf className="text-red-500" />
case 'doc': case 'doc':
case 'docx': case 'docx':
return <FaFileWord className='text-blue-500' />; return <FaFileWord className="text-blue-500" />
case 'xls': case 'xls':
case 'xlsx': case 'xlsx':
return <FaFileExcel className='text-green-500' />; return <FaFileExcel className="text-green-500" />
case 'csv': case 'csv':
return <FaFileCsv className='text-green-500' />; return <FaFileCsv className="text-green-500" />
case 'ppt': case 'ppt':
case 'pptx': case 'pptx':
return <FaFilePowerpoint className='text-red-500' />; return <FaFilePowerpoint className="text-red-500" />
case 'zip': case 'zip':
case 'rar': case 'rar':
case '7z': case '7z':
@@ -79,18 +79,18 @@ const FileIcon = (props: FileIconProps) => {
case 'taz': case 'taz':
case 'tz': case 'tz':
case 'tzo': case 'tzo':
return <FaFileZipper className='text-green-500' />; return <FaFileZipper className="text-green-500" />
case 'txt': case 'txt':
return <FaFileLines className='text-gray-500' />; return <FaFileLines className="text-gray-500" />
case 'mp3': case 'mp3':
case 'wav': case 'wav':
case 'flac': case 'flac':
return <FaFileAudio className='text-green-500' />; return <FaFileAudio className="text-green-500" />
case 'mp4': case 'mp4':
case 'avi': case 'avi':
case 'mov': case 'mov':
case 'wmv': case 'wmv':
return <FaFileVideo className='text-red-500' />; return <FaFileVideo className="text-red-500" />
case 'html': case 'html':
case 'css': case 'css':
case 'js': case 'js':
@@ -154,13 +154,13 @@ const FileIcon = (props: FileIconProps) => {
case 'userosscache': case 'userosscache':
case 'sln.docstates': case 'sln.docstates':
case 'dll': case 'dll':
return <FaFileCode className='text-blue-500' />; return <FaFileCode className="text-blue-500" />
default: default:
return <FaFile className='text-gray-500' />; return <FaFile className="text-gray-500" />
} }
} }
return <FaFile className='text-gray-500' />; return <FaFile className="text-gray-500" />
}; }
export default FileIcon; export default FileIcon

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
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

@@ -0,0 +1,168 @@
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,12 +1,12 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button'
import { Input } from '@heroui/input'; import { Input } from '@heroui/input'
import { import {
Modal, Modal,
ModalBody, ModalBody,
ModalContent, ModalContent,
ModalFooter, ModalFooter,
ModalHeader, ModalHeader
} from '@heroui/modal'; } from '@heroui/modal'
interface RenameModalProps { interface RenameModalProps {
isOpen: boolean isOpen: boolean
@@ -16,29 +16,29 @@ interface RenameModalProps {
onRename: () => void onRename: () => void
} }
export default function RenameModal ({ export default function RenameModal({
isOpen, isOpen,
newFileName, newFileName,
onNameChange, onNameChange,
onClose, onClose,
onRename, onRename
}: RenameModalProps) { }: RenameModalProps) {
return ( return (
<Modal isOpen={isOpen} onClose={onClose}> <Modal isOpen={isOpen} onClose={onClose}>
<ModalContent> <ModalContent>
<ModalHeader></ModalHeader> <ModalHeader></ModalHeader>
<ModalBody> <ModalBody>
<Input label='新名称' value={newFileName} onChange={onNameChange} /> <Input label="新名称" value={newFileName} onChange={onNameChange} />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button color='primary' variant='flat' onPress={onClose}> <Button color="primary" variant="flat" onPress={onClose}>
</Button> </Button>
<Button color='primary' onPress={onRename}> <Button color="primary" onPress={onRename}>
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
); )
} }

View File

@@ -1,4 +1,4 @@
import clsx from 'clsx'; import clsx from 'clsx'
export interface IconWrapperProps { export interface IconWrapperProps {
children?: React.ReactNode children?: React.ReactNode
@@ -14,6 +14,6 @@ const IconWrapper = ({ children, className }: IconWrapperProps) => (
> >
{children} {children}
</div> </div>
); )
export default IconWrapper; export default IconWrapper

View File

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,40 @@
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

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

View File

@@ -0,0 +1,69 @@
import { Button } from '@heroui/button'
import { Input } from '@heroui/input'
import { useRef, useState } from 'react'
export interface FileInputProps {
onChange: (file: File) => Promise<void> | void
onDelete?: () => Promise<void> | void
label?: string
accept?: string
}
const FileInput: React.FC<FileInputProps> = ({
onChange,
onDelete,
label,
accept
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const [isLoading, setIsLoading] = useState(false)
return (
<div className="flex items-end gap-2">
<div className="flex-grow">
<Input
isDisabled={isLoading}
ref={inputRef}
label={label}
type="file"
placeholder="选择文件"
accept={accept}
onChange={async (e) => {
try {
setIsLoading(true)
const file = e.target.files?.[0]
if (file) {
await onChange(file)
}
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
if (inputRef.current) inputRef.current.value = ''
}
}}
/>
</div>
<Button
isDisabled={isLoading}
onPress={async () => {
try {
setIsLoading(true)
if (onDelete) await onDelete()
} catch (error) {
console.error(error)
} finally {
setIsLoading(false)
if (inputRef.current) inputRef.current.value = ''
}
}}
color="primary"
variant="flat"
size="sm"
>
</Button>
</div>
)
}
export default FileInput

View File

@@ -0,0 +1,56 @@
import { Button } from '@heroui/button'
import { Image } from '@heroui/image'
import { Input } from '@heroui/input'
import { useRef } from 'react'
export interface ImageInputProps {
onChange: (base64: string) => void
value: string
label?: string
}
const ImageInput: React.FC<ImageInputProps> = ({ onChange, value, label }) => {
const inputRef = useRef<HTMLInputElement>(null)
return (
<div className="flex items-end gap-2">
<div className="w-5 h-5 flex-shrink-0">
<Image
src={value}
alt={label}
className="w-5 h-5 flex-shrink-0 rounded-none"
/>
</div>
<Input
ref={inputRef}
label={label}
type="file"
placeholder="选择图片"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = async () => {
const base64 = reader.result as string
onChange(base64)
}
reader.readAsDataURL(file)
}
}}
/>
<Button
onPress={() => {
onChange('')
if (inputRef.current) inputRef.current.value = ''
}}
color="primary"
variant="flat"
size="sm"
>
</Button>
</div>
)
}
export default ImageInput

View File

@@ -1,15 +1,15 @@
import { Button } from '@heroui/button'; import { Button } from '@heroui/button'
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card'
import { Select, SelectItem } from '@heroui/select'; import { Select, SelectItem } from '@heroui/select'
import type { Selection } from '@react-types/shared'; import type { Selection } from '@react-types/shared'
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react'
import { colorizeLogLevel } from '@/utils/terminal'; import { colorizeLogLevel } from '@/utils/terminal'
import PageLoading from '../page_loading'; import PageLoading from '../page_loading'
import XTerm from '../xterm'; import XTerm from '../xterm'
import type { XTermRef } from '../xterm'; import type { XTermRef } from '../xterm'
import LogLevelSelect from './log_level_select'; import LogLevelSelect from './log_level_select'
export interface HistoryLogsProps { export interface HistoryLogsProps {
list: string[] list: string[]
@@ -32,80 +32,80 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
listLoading, listLoading,
logContent, logContent,
listError, listError,
logLoading, logLoading
} = props; } = props
const Xterm = useRef<XTermRef>(null); const Xterm = useRef<XTermRef>(null)
const [logLevel, setLogLevel] = useState<Selection>( const [logLevel, setLogLevel] = useState<Selection>(
new Set(['info', 'warn', 'error']) new Set(['info', 'warn', 'error'])
); )
const logToColored = (log: string) => { const logToColored = (log: string) => {
const logs = log const logs = log
.split('\n') .split('\n')
.map((line) => { .map((line) => {
const colored = colorizeLogLevel(line); const colored = colorizeLogLevel(line)
return colored; return colored
}) })
.filter((log) => { .filter((log) => {
if (logLevel === 'all') { if (logLevel === 'all') {
return true; return true
} }
return logLevel.has(log.level); return logLevel.has(log.level)
}) })
.map((log) => log.content) .map((log) => log.content)
.join('\r\n'); .join('\r\n')
return logs; return logs
}; }
const onDownloadLog = () => { const onDownloadLog = () => {
if (!logContent) { if (!logContent) {
return; return
} }
const blob = new Blob([logContent], { type: 'text/plain' }); const blob = new Blob([logContent], { type: 'text/plain' })
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob)
const a = document.createElement('a'); const a = document.createElement('a')
a.href = url; a.href = url
a.download = `${selectedLog}.log`; a.download = `${selectedLog}.log`
a.click(); a.click()
URL.revokeObjectURL(url); URL.revokeObjectURL(url)
}; }
useEffect(() => { useEffect(() => {
if (!Xterm.current || !logContent) { if (!Xterm.current || !logContent) {
return; return
} }
Xterm.current.clear(); Xterm.current.clear()
const _logContent = logToColored(logContent); const _logContent = logToColored(logContent)
Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ '); Xterm.current.write(_logContent + '\r\nnapcat@webui:~$ ')
}, [logContent, logLevel]); }, [logContent, logLevel])
return ( return (
<> <>
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
<Card className='max-w-full h-full bg-opacity-50 backdrop-blur-sm'> <Card className="max-w-full h-full bg-opacity-50 backdrop-blur-sm">
<CardHeader className='flex-row justify-start gap-3'> <CardHeader className="flex-row justify-start gap-3">
<Select <Select
label='选择日志' label="选择日志"
size='sm' size="sm"
isLoading={listLoading} isLoading={listLoading}
errorMessage={listError?.message} errorMessage={listError?.message}
classNames={{ classNames={{
trigger: trigger:
'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60', 'hover:!bg-content3 bg-opacity-50 backdrop-blur-sm hover:!bg-opacity-60'
}} }}
placeholder='选择日志' placeholder="选择日志"
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value
if (!value) { if (!value) {
return; return
} }
onSelect(value); onSelect(value)
}} }}
selectedKeys={[selectedLog || '']} selectedKeys={[selectedLog || '']}
items={list.map((name) => ({ items={list.map((name) => ({
value: name, value: name,
label: name, label: name
}))} }))}
> >
{(item) => ( {(item) => (
@@ -118,19 +118,19 @@ const HistoryLogs: React.FC<HistoryLogsProps> = (props) => {
selectedKeys={logLevel} selectedKeys={logLevel}
onSelectionChange={setLogLevel} onSelectionChange={setLogLevel}
/> />
<Button className='flex-shrink-0' onPress={onDownloadLog}> <Button className="flex-shrink-0" onPress={onDownloadLog}>
</Button> </Button>
<Button onPress={refreshList}></Button> <Button onPress={refreshList}></Button>
<Button onPress={refreshLog}></Button> <Button onPress={refreshLog}></Button>
</CardHeader> </CardHeader>
<CardBody className='relative'> <CardBody className="relative">
<PageLoading loading={logLoading} /> <PageLoading loading={logLoading} />
<XTerm className='w-full h-full' ref={Xterm} /> <XTerm className="w-full h-full" ref={Xterm} />
</CardBody> </CardBody>
</Card> </Card>
</> </>
); )
}; }
export default HistoryLogs; export default HistoryLogs

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