mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-12 16:00:27 +00:00
Compare commits
148 Commits
copilot/fi
...
v4.9.43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0181700c3b | ||
|
|
6083e9cfcc | ||
|
|
5fec190c70 | ||
|
|
e1743ae5e4 | ||
|
|
ded921c55e | ||
|
|
57e717e898 | ||
|
|
55f21c6caa | ||
|
|
4360775eff | ||
|
|
e2486606f9 | ||
|
|
f8eb368cdb | ||
|
|
9ce51fb082 | ||
|
|
89e50be1e9 | ||
|
|
1259dcea5b | ||
|
|
b4900066b3 | ||
|
|
28acd94cbd | ||
|
|
7aedacb27f | ||
|
|
c9b45ec1a2 | ||
|
|
54cacc30e4 | ||
|
|
9b26fc99d3 | ||
|
|
204846b404 | ||
|
|
3d654791b9 | ||
|
|
63f42f1592 | ||
|
|
0ab0b939da | ||
|
|
522a123f9a | ||
|
|
ec9f8d6e12 | ||
|
|
3c750c75a9 | ||
|
|
5b2b1f499b | ||
|
|
531ffcd55d | ||
|
|
068e4d8bb5 | ||
|
|
5dc33e78ad | ||
|
|
d76a2170a0 | ||
|
|
ec2af3120c | ||
|
|
8de49a3109 | ||
|
|
dbb5a0022e | ||
|
|
cb8c8d6b57 | ||
|
|
93c140ed4e | ||
|
|
457b072f0e | ||
|
|
1869493473 | ||
|
|
f33c66ce15 | ||
|
|
e8d6f86458 | ||
|
|
a000ffdf0d | ||
|
|
202338a160 | ||
|
|
e3eb129a52 | ||
|
|
7654e9f2bb | ||
|
|
a60c03f42f | ||
|
|
60aae228a1 | ||
|
|
b1417f9b56 | ||
|
|
eeeaddbb60 | ||
|
|
b1109022bb | ||
|
|
1807789511 | ||
|
|
49a5b631c2 | ||
|
|
e7aaec81e2 | ||
|
|
05f8e8f3c3 | ||
|
|
e760876470 | ||
|
|
e0ec4d4ebb | ||
|
|
79fa0ade0d | ||
|
|
652b5d6118 | ||
|
|
f4dedf4803 | ||
|
|
06f6a542f5 | ||
|
|
d5b8f886d6 | ||
|
|
97b6dccc30 | ||
|
|
a755487e22 | ||
|
|
5407392f08 | ||
|
|
3359c5ded9 | ||
|
|
caaf8be3b2 | ||
|
|
28ce5d3cb4 | ||
|
|
3dd56c711e | ||
|
|
511fb82ce0 | ||
|
|
1e5524a009 | ||
|
|
d5e6afc7b9 | ||
|
|
91e633b0fb | ||
|
|
397b9880b9 | ||
|
|
54eb26ba67 | ||
|
|
355f7fb4a0 | ||
|
|
325c455e38 | ||
|
|
a7a9792efe | ||
|
|
4bab93e545 | ||
|
|
3c60997b1c | ||
|
|
bf684d9166 | ||
|
|
5c4ee30f37 | ||
|
|
346374b442 | ||
|
|
cc8a387bde | ||
|
|
962685ade6 | ||
|
|
36fdaac406 | ||
|
|
db16db911b | ||
|
|
bb84dfcc27 | ||
|
|
3e71e541e6 | ||
|
|
bcc856d583 | ||
|
|
1eaf480a7d | ||
|
|
86123af7fc | ||
|
|
c3cd2aaf89 | ||
|
|
599afdc7ba | ||
|
|
ffe54af8d9 | ||
|
|
a7b30ef844 | ||
|
|
50b8cb14dc | ||
|
|
0b18f868bc | ||
|
|
c9acb9ae28 | ||
|
|
2e1506a05d | ||
|
|
b35283f970 | ||
|
|
54ac072bfb | ||
|
|
58cefb9cdc | ||
|
|
be4344634d | ||
|
|
2da5d242f7 | ||
|
|
fbd00b2576 | ||
|
|
72548c9575 | ||
|
|
003f3e946d | ||
|
|
dc51d01351 | ||
|
|
c5db525f4a | ||
|
|
c1377e6de7 | ||
|
|
d2c4f425c7 | ||
|
|
803b1a6c77 | ||
|
|
9a35ee9cd1 | ||
|
|
458d22223c | ||
|
|
4ed61136b2 | ||
|
|
1445a29e15 | ||
|
|
55ef040852 | ||
|
|
0c88319248 | ||
|
|
6778bd69de | ||
|
|
f9c9b3a852 | ||
|
|
9ce9e46c57 | ||
|
|
656bde25c8 | ||
|
|
791a360f28 | ||
|
|
376245b749 | ||
|
|
d3e3527c2b | ||
|
|
5b78dfbd5a | ||
|
|
1e461aae3c | ||
|
|
42abc2b9cb | ||
|
|
4f8c320658 | ||
|
|
cbacc89907 | ||
|
|
8e6da5e2d0 | ||
|
|
02980c4d1a | ||
|
|
0129188739 | ||
|
|
98ef642cd1 | ||
|
|
32e886e53b | ||
|
|
315d847f06 | ||
|
|
381d320967 | ||
|
|
2f5b62decb | ||
|
|
2afdb2a0da | ||
|
|
5bfbf92c21 | ||
|
|
a775a0dde9 | ||
|
|
d7f00c0594 | ||
|
|
77c8f874b6 | ||
|
|
fb0a20919b | ||
|
|
0300ba4648 | ||
|
|
d472eee777 | ||
|
|
41bd06e50a | ||
|
|
97334dfbf5 | ||
|
|
e3d8c8e940 |
@@ -15,10 +15,10 @@ charset = utf-8
|
||||
# 4 space indentation
|
||||
[*.{cjs,mjs,js,jsx,ts,tsx,css,scss,sass,html,json}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
indent_size = 2
|
||||
|
||||
[*.bat]
|
||||
charset = latin1
|
||||
|
||||
# Unfortunately, EditorConfig doesn't support space configuration inside import braces directly.
|
||||
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
|
||||
# You'll need to rely on your linter/formatter like ESLint or Prettier for that.
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_BUILD_TYPE = Production
|
||||
VITE_BUILD_PLATFORM = Framework
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_BUILD_TYPE = Production
|
||||
VITE_BUILD_PLATFORM = Shell
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_BUILD_TYPE = DEBUG
|
||||
VITE_BUILD_PLATFORM = Shell
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_BUILD_TYPE = Production
|
||||
VITE_BUILD_PLATFORM = Universal
|
||||
61
.github/prompt/release_note_prompt.txt
vendored
Normal file
61
.github/prompt/release_note_prompt.txt
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
注意:输出必须严格使用 NapCat 的发布说明格式,并用简体中文。
|
||||
|
||||
格式规则:
|
||||
1. 第一行:# V{TAG}
|
||||
2. 第二行:[使用文档](https://napneko.github.io/)
|
||||
3. 空行后,按下面的节顺序输出(存在则输出,不存在则省略该节):
|
||||
|
||||
## Windows 一键包
|
||||
- 简短一句话介绍一键包用途
|
||||
- 列出可下载的文件名(只列文件名,不写下载链接)
|
||||
|
||||
## 警告
|
||||
- 如果有需要特别提醒的兼容/运行库/版本要求,写成加粗警告句
|
||||
|
||||
## 如果WinX64缺少运行库或者xxx.dll?
|
||||
- 常见运行库建议
|
||||
|
||||
## 更新
|
||||
按数字序列列出主要变更项,每条尽量一句话
|
||||
- 前缀短 commit id,例如:1. a1b2c3d 修复 get_essence_msg_list 崩溃
|
||||
- 保持 4-18 条要点
|
||||
|
||||
## 开发者注意
|
||||
- 列出迁移/接口断裂/配置变更;若无则省略
|
||||
|
||||
额外约束:
|
||||
- 语言简体中文,面向最终用户
|
||||
- 不输出 stack trace、密钥、敏感信息
|
||||
- 只列文件名作为 assets,不写链接
|
||||
- 若提交为空,输出简短默认说明
|
||||
|
||||
下面为示例
|
||||
|
||||
# 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)
|
||||
|
||||
## 更新
|
||||
1. xxxx
|
||||
166
.github/workflows/auto-release.yml
vendored
Normal file
166
.github/workflows/auto-release.yml
vendored
Normal file
@@ -0,0 +1,166 @@
|
||||
name: Build Release (OpenRouter AI notes on tag)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*' # 任意 tag push 时触发,可改为 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
OPENROUTER_API_URL: https://openrouter.ai/api/v1/chat/completions
|
||||
OPENROUTER_MODEL: "deepseek/deepseek-chat-v3-0324:free"
|
||||
RELEASE_NAME: "NapCat"
|
||||
|
||||
jobs:
|
||||
Build-LiteLoader:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
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 -f package-lock.json
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Framework
|
||||
path: framework-dist
|
||||
|
||||
Build-Shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Shell
|
||||
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 -f package-lock.json
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Shell
|
||||
path: shell-dist
|
||||
|
||||
release-napcat:
|
||||
needs: [Build-LiteLoader, Build-Shell]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
- name: Make zips
|
||||
run: |
|
||||
cd artifacts || exit 0
|
||||
[ -d NapCat.Shell ] && (cd NapCat.Shell && zip -qr ../NapCat.Shell.zip .)
|
||||
[ -d NapCat.Framework ] && (cd NapCat.Framework && zip -qr ../NapCat.Framework.zip .)
|
||||
cd ..
|
||||
ls -la
|
||||
|
||||
- name: Prepare commits list
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
git fetch --all --tags || true
|
||||
PREV_TAG=$(git tag --sort=-creatordate | grep -v "^$" | awk -v t="$TAG" '$0!=t{print; exit}')
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
git log --pretty=format:'%h %s (%an)' "$PREV_TAG..$TAG" > /tmp/commits.txt || git log --pretty=format:'%h %s (%an)' -n 30 > /tmp/commits.txt
|
||||
else
|
||||
git log --pretty=format:'%h %s (%an)' -n 30 > /tmp/commits.txt
|
||||
fi
|
||||
echo "=== commits ==="
|
||||
sed -n '1,200p' /tmp/commits.txt || true
|
||||
|
||||
- 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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PROMPT_FILE=".github/prompt/release_note_prompt.txt"
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
ARTIFACTS_LIST=$(ls ./artifacts 2>/dev/null | tr '\n' ',' | sed 's/,$//')
|
||||
COMMITS=$(sed 's/"/\\"/g' /tmp/commits.txt || echo "无提交信息")
|
||||
USER_CONTENT="$(printf "TAG: %s\nARTIFACTS: %s\n\n提交列表:\n%s" "$TAG" "$ARTIFACTS_LIST" "$COMMITS")"
|
||||
SYSTEM_PROMPT="$(jq -Rs . < "$PROMPT_FILE")"
|
||||
|
||||
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.2, max_tokens:800}')
|
||||
|
||||
RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-d "$BODY")
|
||||
|
||||
echo "$RESPONSE" | jq -r '.choices[0].message.content // .choices[0].text // ""' > /tmp/release_body.txt || true
|
||||
echo "=== generated release note ==="
|
||||
sed -n '1,200p' /tmp/release_body.txt || true
|
||||
|
||||
- name: Create or update release & upload assets
|
||||
env:
|
||||
GHTOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: "${{ github.ref_name }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BODY=$(sed 's/"/\\"/g' /tmp/release_body.txt || echo "Automated release")
|
||||
# check existing release for this tag
|
||||
EXIST=$(curl -s -H "Authorization: token $GHTOKEN" "https://api.github.com/repos/$REPO/releases/tags/$TAG" || true)
|
||||
RID=$(echo "$EXIST" | jq -r '.id // empty')
|
||||
if [ -n "$RID" ]; then
|
||||
echo "Update existing release id $RID"
|
||||
jq -n --arg body "$BODY" '{body:$body}' > /tmp/update.json
|
||||
curl -s -X PATCH -H "Authorization: token $GHTOKEN" -H "Content-Type: application/json" \
|
||||
"https://api.github.com/repos/$REPO/releases/$RID" -d @/tmp/update.json | jq -r '.id'
|
||||
else
|
||||
echo "Create release for tag $TAG"
|
||||
jq -n --arg tag "$TAG" --arg name "${RELEASE_NAME} $TAG" --arg body "$BODY" \
|
||||
'{tag_name:$tag, name:$name, body:$body, draft:true, prerelease:false}' > /tmp/create.json
|
||||
CREATE_RESP=$(curl -s -X POST -H "Authorization: token $GHTOKEN" -H "Content-Type: application/json" \
|
||||
"https://api.github.com/repos/$REPO/releases" -d @/tmp/create.json)
|
||||
RID=$(echo "$CREATE_RESP" | jq -r '.id')
|
||||
fi
|
||||
|
||||
upload() {
|
||||
f="$1"
|
||||
[ -f "$f" ] || { echo "skip $f"; return; }
|
||||
NAME=$(basename "$f")
|
||||
UPLOAD_URL=$(curl -s -H "Authorization: token $GHTOKEN" "https://api.github.com/repos/$REPO/releases/$RID" | jq -r '.upload_url')
|
||||
UPLOAD_URL="${UPLOAD_URL%\{*}?name=${NAME}"
|
||||
echo "Uploading $NAME..."
|
||||
curl -s -X POST -H "Authorization: token $GHTOKEN" -H "Content-Type: application/zip" --data-binary @"$f" "$UPLOAD_URL" | jq -r '.id'
|
||||
}
|
||||
|
||||
upload "./artifacts/NapCat.Framework.zip"
|
||||
upload "./artifacts/NapCat.Shell.zip"
|
||||
echo "done"
|
||||
31
.github/workflows/build.yml
vendored
31
.github/workflows/build.yml
vendored
@@ -1,8 +1,9 @@
|
||||
name: "Build Action"
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: write-all
|
||||
|
||||
@@ -18,14 +19,19 @@ jobs:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Framework
|
||||
run: |
|
||||
npm i && cd napcat.webui && npm i && cd .. || exit 1
|
||||
npm run build:framework && npm run depend || exit 1
|
||||
rm package-lock.json
|
||||
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: dist
|
||||
path: framework-dist
|
||||
Build-Shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -37,11 +43,16 @@ jobs:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Shell
|
||||
run: |
|
||||
npm i && cd napcat.webui && npm i && cd .. || exit 1
|
||||
npm run build:shell && npm run depend || exit 1
|
||||
rm package-lock.json
|
||||
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: dist
|
||||
path: shell-dist
|
||||
|
||||
204
.github/workflows/release.yml
vendored
204
.github/workflows/release.yml
vendored
@@ -1,164 +1,88 @@
|
||||
name: "Build Release"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
Build-LiteLoader:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone Repository
|
||||
- name: Clone Main Repository
|
||||
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
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-version]
|
||||
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 Shell
|
||||
run: |
|
||||
npm i
|
||||
cd napcat.webui
|
||||
npm i
|
||||
cd ..
|
||||
npm run build:shell
|
||||
cd dist
|
||||
npm i --omit=dev
|
||||
cd ..
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: NapCat.Shell
|
||||
path: dist
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js 20.X
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Build NapCat.Shell
|
||||
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
|
||||
|
||||
release-napcat:
|
||||
needs: [Build-LiteLoader,Build-Shell]
|
||||
needs: [Build-LiteLoader, Build-Shell]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download All Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Clone Main Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'NapNeko/NapCatQQ'
|
||||
submodules: true
|
||||
ref: main
|
||||
token: ${{ secrets.NAPCAT_BUILD }}
|
||||
|
||||
- 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
|
||||
- name: Compress subdirectories
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.NAPCAT_BUILD }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/NapNeko/NapCat-Docker/actions/workflows/docker-publish.yml/dispatches \
|
||||
-d '{"ref": "main"}'
|
||||
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 ./
|
||||
|
||||
- name: Create Release Draft and Upload Artifacts
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: NapCat
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
body: Automated release artifact (no version detection)
|
||||
files: |
|
||||
NapCat.Framework.zip
|
||||
NapCat.Shell.zip
|
||||
draft: true
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,3 +14,6 @@ devconfig/*
|
||||
*.db
|
||||
checkVersion.sh
|
||||
bun.lockb
|
||||
tests/run/
|
||||
guild1.db-wal
|
||||
guild1.db-shm
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 120,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
115
.vscode/launch.json
vendored
115
.vscode/launch.json
vendored
@@ -1,115 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:shell",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "build:shell",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"build:shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "build:universal",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"build:universal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "build:framework",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"build:framework"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "build:webui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"build:webui"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:universal",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:universal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:framework",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:framework"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:webui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:webui"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "lint",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"lint"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "depend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"depend"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "dev:depend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev:depend"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@@ -1,17 +1,2 @@
|
||||
{
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.expand": false,
|
||||
"explorer.fileNesting.patterns": {
|
||||
".env.universal": ".env.*",
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, vite.config.ts",
|
||||
"package.json": "package-lock.json, eslint*, .prettier*, .editorconfig, manifest.json, logo.png, .gitignore, LICENSE"
|
||||
},
|
||||
"css.customData": [
|
||||
".vscode/tailwindcss.json"
|
||||
],
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "never"
|
||||
},
|
||||
}
|
||||
}
|
||||
55
.vscode/tailwindcss.json
vendored
55
.vscode/tailwindcss.json
vendored
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"version": 1.1,
|
||||
"atDirectives": [
|
||||
{
|
||||
"name": "@tailwind",
|
||||
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@apply",
|
||||
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@responsive",
|
||||
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@screen",
|
||||
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "@variants",
|
||||
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
|
||||
"references": [
|
||||
{
|
||||
"name": "Tailwind Documentation",
|
||||
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
28
README.md
28
README.md
@@ -3,8 +3,6 @@
|
||||
|
||||
# NapCat
|
||||
|
||||
|
||||
|
||||
_Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
> 云起兮风生,心向远方兮路未曾至.
|
||||
@@ -14,6 +12,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
---
|
||||
|
||||
## New Feature
|
||||
|
||||
在 v4.8.115+ 版本开始
|
||||
|
||||
1. NapCatQQ 支持 [Stream Api](https://napneko.github.io/develop/file)
|
||||
@@ -23,19 +22,21 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
- [2] 采用字符串可以解决扩展到int64的问题,同时也可以解决部分语言(如JavaScript)对大整数支持不佳的问题,增加极少成本。
|
||||
|
||||
## 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 协议端实现
|
||||
|
||||
## 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
|
||||
|
||||
可前往 [Release](https://github.com/NapNeko/NapCatQQ/releases/) 页面下载最新版本
|
||||
@@ -43,6 +44,7 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
**首次使用**请务必查看如下文档看使用教程
|
||||
|
||||
> 项目非盈利,对接问题/基础问题/下层框架问题 请自行搜索解决,本项目社区不提供此类解答。
|
||||
|
||||
## Link
|
||||
|
||||
| Docs | [](https://napneko.github.io/) | [](https://doc.napneko.icu/) | [](https://napcat.napneko.icu/) |
|
||||
@@ -64,20 +66,22 @@ _Modern protocol-side framework implemented based on NTQQ._
|
||||
|
||||
## 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
|
||||
|
||||
本项目采用 混合协议 开源,因此使用本项目时,你需要注意以下几点:
|
||||
|
||||
1. 第三方库代码或修改部分遵循其原始开源许可.
|
||||
2. 本项目获取部分项目授权而不受部分约束
|
||||
2. 项目其余逻辑代码采用[本仓库开源许可](./LICENSE).
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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
BIN
external/LiteLoaderWrapper.zip
vendored
Binary file not shown.
BIN
external/logo.png
vendored
BIN
external/logo.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 250 KiB |
Binary file not shown.
BIN
logo.png
BIN
logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 684 KiB After Width: | Height: | Size: 250 KiB |
@@ -1,7 +0,0 @@
|
||||
dist
|
||||
*.md
|
||||
*.html
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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
15995
napcat.webui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
||||
import { Suspense, lazy, useEffect } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom'
|
||||
|
||||
import PageBackground from '@/components/page_background'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
import Toaster from '@/components/toaster'
|
||||
|
||||
import DialogProvider from '@/contexts/dialog'
|
||||
import AudioProvider from '@/contexts/songs'
|
||||
|
||||
import useAuth from '@/hooks/auth'
|
||||
|
||||
import store from '@/store'
|
||||
|
||||
const WebLoginPage = lazy(() => import('@/pages/web_login'))
|
||||
const IndexPage = lazy(() => import('@/pages/index'))
|
||||
const QQLoginPage = lazy(() => import('@/pages/qq_login'))
|
||||
const DashboardIndexPage = lazy(() => import('@/pages/dashboard'))
|
||||
const AboutPage = lazy(() => import('@/pages/dashboard/about'))
|
||||
const ConfigPage = lazy(() => import('@/pages/dashboard/config'))
|
||||
const DebugPage = lazy(() => import('@/pages/dashboard/debug'))
|
||||
const HttpDebug = lazy(() => import('@/pages/dashboard/debug/http'))
|
||||
const WSDebug = lazy(() => import('@/pages/dashboard/debug/websocket'))
|
||||
const FileManagerPage = lazy(() => import('@/pages/dashboard/file_manager'))
|
||||
const LogsPage = lazy(() => import('@/pages/dashboard/logs'))
|
||||
const NetworkPage = lazy(() => import('@/pages/dashboard/network'))
|
||||
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<Provider store={store}>
|
||||
<PageBackground />
|
||||
<Toaster />
|
||||
<AudioProvider>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<AuthChecker>
|
||||
<AppRoutes />
|
||||
</AuthChecker>
|
||||
</Suspense>
|
||||
</AudioProvider>
|
||||
</Provider>
|
||||
</DialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthChecker({ children }: { children: React.ReactNode }) {
|
||||
const { isAuth } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuth) {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const token = search.get('token')
|
||||
let url = '/web_login'
|
||||
|
||||
if (token) {
|
||||
url += `?token=${token}`
|
||||
}
|
||||
navigate(url, { replace: true })
|
||||
}
|
||||
}, [isAuth, navigate])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<IndexPage />}>
|
||||
<Route index element={<DashboardIndexPage />} />
|
||||
<Route path="network" element={<NetworkPage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="debug" element={<DebugPage />}>
|
||||
<Route path="ws" element={<WSDebug />} />
|
||||
<Route path="http" element={<HttpDebug />} />
|
||||
</Route>
|
||||
<Route path="file_manager" element={<FileManagerPage />} />
|
||||
<Route path="terminal" element={<TerminalPage />} />
|
||||
<Route path="about" element={<AboutPage />} />
|
||||
</Route>
|
||||
<Route path="/qq_login" element={<QQLoginPage />} />
|
||||
<Route path="/web_login" element={<WebLoginPage />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,254 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { FaMicrophone } from 'react-icons/fa6'
|
||||
import { IoMic } from 'react-icons/io5'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const AudioInsert = () => {
|
||||
const [audioUrl, setAudioUrl] = useState<string>('')
|
||||
const audioInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showAudioSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'record',
|
||||
data: {
|
||||
file: 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
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { BsDice3Fill } from 'react-icons/bs'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
const DiceInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
|
||||
return (
|
||||
<Tooltip content="发送骰子">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'dice'
|
||||
}
|
||||
])
|
||||
}}
|
||||
>
|
||||
<BsDice3Fill className="text-lg" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default DiceInsert
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { FaFolder } from 'react-icons/fa6'
|
||||
import { LuFilePlus2 } from 'react-icons/lu'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const FileInsert = () => {
|
||||
const [fileUrl, setFileUrl] = useState<string>('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showFileSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'file',
|
||||
data: {
|
||||
file: 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
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { MdAddPhotoAlternate, MdEdit, MdImage, MdUpload } from 'react-icons/md'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
export interface ImageInsertProps {
|
||||
insertImage: (url: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => {
|
||||
const [imgUrl, setImgUrl] = useState<string>('')
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={onOpenChange}>
|
||||
<Tooltip content="插入图片">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||
<MdImage className="text-xl" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Tooltip content="上传图片">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="primary"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
imageInputRef?.current?.click()
|
||||
}}
|
||||
>
|
||||
<MdUpload />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popover>
|
||||
<Tooltip content="输入图片地址">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger tooltip="输入图片地址">
|
||||
<Button
|
||||
className="text-lg"
|
||||
color="primary"
|
||||
isIconOnly
|
||||
variant="flat"
|
||||
radius="full"
|
||||
>
|
||||
<MdEdit />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-1 p-2">
|
||||
<Input
|
||||
value={imgUrl}
|
||||
onChange={(e) => setImgUrl(e.target.value)}
|
||||
placeholder="请输入图片地址"
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
if (!isURI(imgUrl)) {
|
||||
toast.error('请输入正确的图片地址')
|
||||
return
|
||||
}
|
||||
insertImage(imgUrl)
|
||||
setImgUrl('')
|
||||
}}
|
||||
>
|
||||
<MdAddPhotoAlternate />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={imageInputRef}
|
||||
hidden
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (event) => {
|
||||
const dataURL = event.target?.result
|
||||
insertImage(dataURL as string)
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageInsert
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useState } from 'react'
|
||||
import { BsChatQuoteFill } from 'react-icons/bs'
|
||||
import { MdAdd } from 'react-icons/md'
|
||||
|
||||
export interface ReplyInsertProps {
|
||||
insertReply: (messageId: string) => void
|
||||
}
|
||||
|
||||
const ReplyInsert = ({ insertReply }: ReplyInsertProps) => {
|
||||
const [replyId, setReplyId] = useState<string>('')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<Tooltip content="回复消息">
|
||||
<div className="max-w-fit">
|
||||
<PopoverTrigger>
|
||||
<Button color="primary" variant="flat" isIconOnly radius="full">
|
||||
<BsChatQuoteFill className="text-lg" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PopoverContent className="flex-row gap-2 p-4">
|
||||
<Input
|
||||
placeholder="输入消息 ID"
|
||||
value={replyId}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/
|
||||
if (isNumberReg.test(value)) {
|
||||
setReplyId(value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={() => {
|
||||
insertReply(replyId)
|
||||
setReplyId('')
|
||||
}}
|
||||
>
|
||||
<MdAdd />
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReplyInsert
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { LiaHandScissors } from 'react-icons/lia'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
const RPSInsert = () => {
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
|
||||
return (
|
||||
<Tooltip content="发送猜拳">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
onPress={() => {
|
||||
showStructuredMessage([
|
||||
{
|
||||
type: 'rps'
|
||||
}
|
||||
])
|
||||
}}
|
||||
>
|
||||
<LiaHandScissors className="text-2xl" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default RPSInsert
|
||||
@@ -1,126 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@heroui/popover'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoVideocam } from 'react-icons/io5'
|
||||
import { MdEdit, MdUpload } from 'react-icons/md'
|
||||
import { TbVideoPlus } from 'react-icons/tb'
|
||||
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { isURI } from '@/utils/url'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
const VideoInsert = () => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>('')
|
||||
const videoInputRef = useRef<HTMLInputElement>(null)
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const showVideoSegment = (file: string) => {
|
||||
const messages: OB11Segment[] = [
|
||||
{
|
||||
type: 'video',
|
||||
data: {
|
||||
file: 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
|
||||
@@ -1,41 +0,0 @@
|
||||
import Quill from 'quill'
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface EmojiValue {
|
||||
alt: string
|
||||
src: string
|
||||
id: string
|
||||
}
|
||||
class EmojiBlot extends Embed {
|
||||
static blotName: string = 'emoji'
|
||||
static tagName: string = 'img'
|
||||
static classNames: string[] = ['w-6', 'h-6']
|
||||
|
||||
static create(value: HTMLImageElement) {
|
||||
const node = super.create(value)
|
||||
node.setAttribute('alt', value.alt)
|
||||
node.setAttribute('src', value.src)
|
||||
node.setAttribute('data-id', value.id)
|
||||
node.classList.add(...EmojiBlot.classNames)
|
||||
return node
|
||||
}
|
||||
|
||||
static formats(node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
static value(node: HTMLImageElement): EmojiValue {
|
||||
return {
|
||||
alt: node.getAttribute('alt') ?? '',
|
||||
src: node.getAttribute('src') ?? '',
|
||||
id: node.getAttribute('data-id') ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EmojiBlot
|
||||
@@ -1,30 +0,0 @@
|
||||
import Quill from 'quill'
|
||||
|
||||
// eslint-disable-next-line
|
||||
const Embed = Quill.import('blots/embed') as any
|
||||
export interface ImageValue {
|
||||
alt: string
|
||||
src: string
|
||||
}
|
||||
class ImageBlot extends Embed {
|
||||
static blotName = 'image'
|
||||
static tagName = 'img'
|
||||
static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom']
|
||||
|
||||
static create(value: ImageValue) {
|
||||
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
|
||||
@@ -1,207 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import type { Range } from 'quill'
|
||||
import 'quill/dist/quill.core.css'
|
||||
import { useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { useCustomQuill } from '@/hooks/use_custom_quill'
|
||||
import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'
|
||||
|
||||
import { quillToMessage } from '@/utils/onebot'
|
||||
|
||||
import type { OB11Segment } from '@/types/onebot'
|
||||
|
||||
import AudioInsert from './components/audio_insert'
|
||||
import DiceInsert from './components/dice_insert'
|
||||
import EmojiPicker from './components/emoji_picker'
|
||||
import FileInsert from './components/file_insert'
|
||||
import ImageInsert from './components/image_insert'
|
||||
import MusicInsert from './components/music_insert'
|
||||
import ReplyInsert from './components/reply_insert'
|
||||
import RPSInsert from './components/rps_insert'
|
||||
import VideoInsert from './components/video_insert'
|
||||
import EmojiBlot from './formats/emoji_blot'
|
||||
import type { EmojiValue } from './formats/emoji_blot'
|
||||
import ImageBlot from './formats/image_blot'
|
||||
import ReplyBlock from './formats/reply_blot'
|
||||
|
||||
const ChatInput = () => {
|
||||
const memorizedRange = useRef<Range | null>(null)
|
||||
|
||||
const showStructuredMessage = useShowStructuredMessage()
|
||||
const formats: string[] = ['image', 'emoji', 'reply']
|
||||
const modules = {
|
||||
toolbar: '#toolbar'
|
||||
}
|
||||
const { quillRef, quill, Quill } = useCustomQuill({
|
||||
modules,
|
||||
formats,
|
||||
placeholder: '请输入消息'
|
||||
})
|
||||
|
||||
if (Quill && !quill) {
|
||||
Quill.register('formats/emoji', EmojiBlot)
|
||||
Quill.register('formats/image', ImageBlot, true)
|
||||
Quill.register('formats/reply', ReplyBlock)
|
||||
}
|
||||
|
||||
if (quill) {
|
||||
quill.on('selection-change', (range) => {
|
||||
if (range) {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
|
||||
if (
|
||||
typeof firstOp?.insert !== 'string' &&
|
||||
firstOp?.insert?.reply &&
|
||||
range.index === 0 &&
|
||||
range.length !== quill.getLength()
|
||||
) {
|
||||
quill.setSelection(1, Quill.sources.SILENT)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
quill.on('text-change', () => {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
})
|
||||
|
||||
quill.on('editor-change', (eventName: string) => {
|
||||
if (eventName === 'text-change') {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
quill.root.addEventListener('compositionstart', () => {
|
||||
const editorContent = quill.getContents()
|
||||
const firstOp = editorContent.ops[0]
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply &&
|
||||
quill.getLength() === 1
|
||||
) {
|
||||
quill.insertText(1, '\n', Quill.sources.SILENT)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
const selection = quill?.getSelection()
|
||||
if (selection) memorizedRange.current = selection
|
||||
}
|
||||
}
|
||||
|
||||
const insertImage = (url: string) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection()
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0)
|
||||
quill?.insertEmbed(selection?.index || 0, 'image', {
|
||||
src: url,
|
||||
alt: '图片'
|
||||
})
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0)
|
||||
}
|
||||
function insertReplyBlock(messageId: string) {
|
||||
const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/
|
||||
if (!isNumberReg.test(messageId)) {
|
||||
toast.error('请输入正确的消息ID')
|
||||
return
|
||||
}
|
||||
const editorContent = quill?.getContents()
|
||||
const firstOp = editorContent?.ops[0]
|
||||
const currentSelection = quill?.getSelection()
|
||||
if (
|
||||
firstOp &&
|
||||
typeof firstOp.insert !== 'string' &&
|
||||
firstOp.insert?.reply
|
||||
) {
|
||||
const delta = quill?.getContents()
|
||||
if (delta) {
|
||||
delta.ops[0] = {
|
||||
insert: { reply: { messageId } }
|
||||
}
|
||||
quill?.setContents(delta, Quill.sources.USER)
|
||||
}
|
||||
} else {
|
||||
quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER)
|
||||
}
|
||||
quill?.setSelection((currentSelection?.index || 0) + 1, 0)
|
||||
quill?.blur()
|
||||
}
|
||||
const onInsertEmoji = (emoji: EmojiValue) => {
|
||||
const selection = memorizedRange.current || quill?.getSelection()
|
||||
quill?.deleteText(selection?.index || 0, selection?.length || 0)
|
||||
quill?.insertEmbed(selection?.index || 0, 'emoji', {
|
||||
alt: emoji.alt,
|
||||
src: emoji.src,
|
||||
id: emoji.id
|
||||
})
|
||||
quill?.setSelection((selection?.index || 0) + 1, 0)
|
||||
}
|
||||
|
||||
const getChatMessage = () => {
|
||||
const delta = quill?.getContents()
|
||||
const ops =
|
||||
delta?.ops?.filter((op) => {
|
||||
return op.insert !== '\n'
|
||||
}) ?? []
|
||||
const messages: OB11Segment[] = ops.map((op) => {
|
||||
return quillToMessage(op)
|
||||
})
|
||||
return messages
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={quillRef}
|
||||
className="border border-default-200 rounded-md !mb-2 !text-base !h-64"
|
||||
/>
|
||||
<div id="toolbar" className="!border-none flex gap-2">
|
||||
<ImageInsert insertImage={insertImage} onOpenChange={onOpenChange} />
|
||||
<EmojiPicker
|
||||
onInsertEmoji={onInsertEmoji}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
<ReplyInsert insertReply={insertReplyBlock} />
|
||||
<FileInsert />
|
||||
<AudioInsert />
|
||||
<VideoInsert />
|
||||
<MusicInsert />
|
||||
<DiceInsert />
|
||||
<RPSInsert />
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
const messages = getChatMessage()
|
||||
showStructuredMessage(messages)
|
||||
}}
|
||||
className="ml-auto"
|
||||
>
|
||||
获取JSON格式
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInput
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Code } from '@heroui/code'
|
||||
import { MdError } from 'react-icons/md'
|
||||
|
||||
export interface ErrorFallbackProps {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}
|
||||
function errorFallbackRender({
|
||||
error,
|
||||
resetErrorBoundary
|
||||
}: ErrorFallbackProps) {
|
||||
return (
|
||||
<div className="pt-32 flex flex-col justify-center items-center">
|
||||
<div className="flex items-center">
|
||||
<MdError className="mr-2" color="red" size={30} />
|
||||
<h1 className="text-2xl">出错了</h1>
|
||||
</div>
|
||||
<div className="my-6 flex flex-col justify-center items-center">
|
||||
<p className="mb-2">错误信息</p>
|
||||
<Code>{error.message}</Code>
|
||||
</div>
|
||||
<Button color="primary" size="md" onPress={resetErrorBoundary}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default errorFallbackRender
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Code } from '@heroui/code'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from '@heroui/modal'
|
||||
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
|
||||
interface FileEditModalProps {
|
||||
isOpen: boolean
|
||||
file: { path: string; content: string } | null
|
||||
onClose: () => void
|
||||
onSave: () => void
|
||||
onContentChange: (newContent?: string) => void
|
||||
}
|
||||
|
||||
export default function FileEditModal({
|
||||
isOpen,
|
||||
file,
|
||||
onClose,
|
||||
onSave,
|
||||
onContentChange
|
||||
}: FileEditModalProps) {
|
||||
// 根据文件后缀返回对应语言
|
||||
const getLanguage = (filePath: string) => {
|
||||
if (filePath.endsWith('.js')) return 'javascript'
|
||||
if (filePath.endsWith('.ts')) return 'typescript'
|
||||
if (filePath.endsWith('.tsx')) return 'tsx'
|
||||
if (filePath.endsWith('.jsx')) return 'jsx'
|
||||
if (filePath.endsWith('.vue')) return 'vue'
|
||||
if (filePath.endsWith('.svelte')) return 'svelte'
|
||||
if (filePath.endsWith('.json')) return 'json'
|
||||
if (filePath.endsWith('.html')) return 'html'
|
||||
if (filePath.endsWith('.css')) return 'css'
|
||||
if (filePath.endsWith('.scss')) return 'scss'
|
||||
if (filePath.endsWith('.less')) return 'less'
|
||||
if (filePath.endsWith('.md')) return 'markdown'
|
||||
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) return 'yaml'
|
||||
if (filePath.endsWith('.xml')) return 'xml'
|
||||
if (filePath.endsWith('.sql')) return 'sql'
|
||||
if (filePath.endsWith('.sh')) return 'shell'
|
||||
if (filePath.endsWith('.bat')) return 'bat'
|
||||
if (filePath.endsWith('.php')) return 'php'
|
||||
if (filePath.endsWith('.java')) return 'java'
|
||||
if (filePath.endsWith('.c')) return 'c'
|
||||
if (filePath.endsWith('.cpp')) return 'cpp'
|
||||
if (filePath.endsWith('.h')) return 'h'
|
||||
if (filePath.endsWith('.hpp')) return 'hpp'
|
||||
if (filePath.endsWith('.go')) return 'go'
|
||||
if (filePath.endsWith('.py')) return 'python'
|
||||
if (filePath.endsWith('.rb')) return 'ruby'
|
||||
if (filePath.endsWith('.cs')) return 'csharp'
|
||||
if (filePath.endsWith('.swift')) return 'swift'
|
||||
if (filePath.endsWith('.vb')) return 'vb'
|
||||
if (filePath.endsWith('.lua')) return 'lua'
|
||||
if (filePath.endsWith('.pl')) return 'perl'
|
||||
if (filePath.endsWith('.r')) return 'r'
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal size="full" isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex items-center gap-2 bg-content2 bg-opacity-50">
|
||||
<span>编辑文件</span>
|
||||
<Code className="text-xs">{file?.path}</Code>
|
||||
</ModalHeader>
|
||||
<ModalBody className="p-0">
|
||||
<div className="h-full">
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
value={file?.content || ''}
|
||||
onChange={onContentChange}
|
||||
options={{ wordWrap: 'on' }}
|
||||
language={file?.path ? getLanguage(file.path) : 'plaintext'}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" onPress={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { useRequest } from 'ahooks'
|
||||
import path from 'path-browserify'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import FileManager from '@/controllers/file_manager'
|
||||
|
||||
import FileIcon from '../file_icon'
|
||||
|
||||
export interface PreviewImage {
|
||||
key: string
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
export const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp']
|
||||
|
||||
export interface ImageNameButtonProps {
|
||||
name: string
|
||||
filePath: string
|
||||
onPreview: () => void
|
||||
onAddPreview: (image: PreviewImage) => void
|
||||
}
|
||||
|
||||
export default function ImageNameButton({
|
||||
name,
|
||||
filePath,
|
||||
onPreview,
|
||||
onAddPreview
|
||||
}: ImageNameButtonProps) {
|
||||
const { data, loading, error, run } = useRequest(
|
||||
async () => FileManager.downloadToURL(filePath),
|
||||
{
|
||||
refreshDeps: [filePath],
|
||||
manual: true,
|
||||
refreshDepsAction: () => {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (!filePath || !imageExts.includes(ext)) {
|
||||
return
|
||||
}
|
||||
run()
|
||||
}
|
||||
}
|
||||
)
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
onAddPreview({
|
||||
key: name,
|
||||
src: data,
|
||||
alt: name
|
||||
})
|
||||
}
|
||||
}, [data, name, onAddPreview])
|
||||
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
run()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="light"
|
||||
className="text-left justify-start"
|
||||
onPress={onPreview}
|
||||
startContent={
|
||||
error ? (
|
||||
<FileIcon name={name} isDirectory={false} />
|
||||
) : loading || !data ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<Image
|
||||
src={data}
|
||||
alt={name}
|
||||
className="w-8 h-8 flex-shrink-0"
|
||||
classNames={{
|
||||
wrapper: 'w-8 h-8 flex-shrink-0'
|
||||
}}
|
||||
radius="sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from '@heroui/modal'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import clsx from 'clsx'
|
||||
import path from 'path-browserify'
|
||||
import { useState } from 'react'
|
||||
import { IoAdd, IoRemove } from 'react-icons/io5'
|
||||
|
||||
import FileManager from '@/controllers/file_manager'
|
||||
|
||||
interface MoveModalProps {
|
||||
isOpen: boolean
|
||||
moveTargetPath: string
|
||||
selectionInfo: string
|
||||
onClose: () => void
|
||||
onMove: () => void
|
||||
onSelect: (dir: string) => void // 新增回调
|
||||
}
|
||||
|
||||
// 将 DirectoryTree 改为递归组件
|
||||
// 新增 selectedPath 属性,用于标识当前选中的目录
|
||||
function DirectoryTree({
|
||||
basePath,
|
||||
onSelect,
|
||||
selectedPath
|
||||
}: {
|
||||
basePath: string
|
||||
onSelect: (dir: string) => void
|
||||
selectedPath?: string
|
||||
}) {
|
||||
const [dirs, setDirs] = useState<string[]>([])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
// 新增loading状态
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchDirectories = async () => {
|
||||
try {
|
||||
// 直接使用 basePath 调用接口,移除 process.platform 判断
|
||||
const list = await FileManager.listDirectories(basePath)
|
||||
setDirs(list.map((item) => item.name))
|
||||
} catch (error) {
|
||||
// ...error handling...
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (!expanded) {
|
||||
setExpanded(true)
|
||||
setLoading(true)
|
||||
await fetchDirectories()
|
||||
setLoading(false)
|
||||
} else {
|
||||
setExpanded(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
onSelect(basePath)
|
||||
handleToggle()
|
||||
}
|
||||
|
||||
// 计算显示的名称
|
||||
const getDisplayName = () => {
|
||||
if (basePath === '/') return '/'
|
||||
if (/^[A-Z]:$/i.test(basePath)) return basePath
|
||||
return path.basename(basePath)
|
||||
}
|
||||
|
||||
// 更新 Button 的 variant 逻辑
|
||||
const isSeleted = selectedPath === basePath
|
||||
const variant = isSeleted
|
||||
? 'solid'
|
||||
: selectedPath && path.dirname(selectedPath) === basePath
|
||||
? 'flat'
|
||||
: 'light'
|
||||
|
||||
return (
|
||||
<div className="ml-4">
|
||||
<Button
|
||||
onPress={handleClick}
|
||||
className="py-1 px-2 text-left justify-start min-w-0 min-h-0 h-auto text-sm rounded-md"
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant={variant}
|
||||
startContent={
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-md',
|
||||
isSeleted ? 'bg-primary-600' : 'bg-primary-50'
|
||||
)}
|
||||
>
|
||||
{expanded ? <IoRemove /> : <IoAdd />}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{getDisplayName()}
|
||||
</Button>
|
||||
{expanded && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="flex py-1 px-8">
|
||||
<Spinner size="sm" color="primary" />
|
||||
</div>
|
||||
) : (
|
||||
dirs.map((dirName) => {
|
||||
const childPath =
|
||||
basePath === '/' && /^[A-Z]:$/i.test(dirName)
|
||||
? dirName
|
||||
: path.join(basePath, dirName)
|
||||
return (
|
||||
<DirectoryTree
|
||||
key={childPath}
|
||||
basePath={childPath}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MoveModal({
|
||||
isOpen,
|
||||
moveTargetPath,
|
||||
selectionInfo,
|
||||
onClose,
|
||||
onMove,
|
||||
onSelect
|
||||
}: MoveModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择目标目录</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="rounded-md p-2 border border-default-300 overflow-auto max-h-60">
|
||||
<DirectoryTree
|
||||
basePath="/"
|
||||
onSelect={onSelect}
|
||||
selectedPath={moveTargetPath}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-default-500 mt-2">
|
||||
当前选择:{moveTargetPath || '未选择'}
|
||||
</p>
|
||||
<p className="text-sm text-default-500">移动项:{selectionInfo}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" onPress={onMove}>
|
||||
确定
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChevronRightIcon } from '../icons'
|
||||
|
||||
const ItemCounter = ({ number }: { number: number }) => (
|
||||
<div className="flex items-center gap-1 text-default-400">
|
||||
<span className="text-small">{number}</span>
|
||||
<ChevronRightIcon className="text-xl" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default ItemCounter
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { getReleaseTime } from '@/utils/time'
|
||||
|
||||
import type { GithubRelease as GithubReleaseType } from '@/types/github'
|
||||
|
||||
export interface GithubReleaseProps {
|
||||
releaseData: GithubReleaseType
|
||||
}
|
||||
const GithubRelease: React.FC<GithubReleaseProps> = (props) => {
|
||||
const { releaseData } = props
|
||||
const [releaseTime, setReleaseTime] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (releaseData) {
|
||||
const timer = setInterval(() => {
|
||||
const time = getReleaseTime(releaseData.published_at)
|
||||
|
||||
setReleaseTime(time)
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}
|
||||
}, [releaseData])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>Releases</span>
|
||||
<div className="px-2 py-1 rounded-small bg-default-100 bg-opacity-50 backdrop-blur-sm group-data-[hover=true]:bg-default-200">
|
||||
<span className="text-tiny text-default-600">{releaseData.name}</span>
|
||||
<div className="flex gap-2 text-tiny">
|
||||
<span className="text-default-500">{releaseTime}</span>
|
||||
<span className="text-success">Latest</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GithubRelease
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoCopy, IoRefresh } from 'react-icons/io5'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
import PageLoading from './page_loading'
|
||||
|
||||
export default function Hitokoto() {
|
||||
const {
|
||||
data: dataOri,
|
||||
error,
|
||||
loading,
|
||||
run
|
||||
} = useRequest(() => request.get<IHitokoto>('https://hitokoto.152710.xyz/'), {
|
||||
pollingInterval: 10000,
|
||||
throttleWait: 1000
|
||||
})
|
||||
const data = dataOri?.data
|
||||
const onCopy = () => {
|
||||
try {
|
||||
const text = `${data?.hitokoto} —— ${data?.from} ${data?.from_who}`
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success('复制成功')
|
||||
} catch (error) {
|
||||
toast.error('复制失败, 请手动复制')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
{loading && <PageLoading />}
|
||||
{error ? (
|
||||
<div className="text-primary-400">一言加载失败:{error.message}</div>
|
||||
) : (
|
||||
<>
|
||||
<div>{data?.hitokoto}</div>
|
||||
<div className="text-right">
|
||||
—— <span className="text-default-400">{data?.from}</span>{' '}
|
||||
{data?.from_who}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Tooltip content="刷新" placement="top">
|
||||
<Button
|
||||
onPress={run}
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
>
|
||||
<IoRefresh />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="复制" placement="top">
|
||||
<Button
|
||||
onPress={onCopy}
|
||||
size="sm"
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="success"
|
||||
variant="flat"
|
||||
>
|
||||
<IoCopy />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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
|
||||
@@ -1,56 +0,0 @@
|
||||
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
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import type { Selection } from '@react-types/shared'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoDownloadOutline } from 'react-icons/io5'
|
||||
|
||||
import { colorizeLogLevelWithTag } from '@/utils/terminal'
|
||||
|
||||
import WebUIManager, { Log } from '@/controllers/webui_manager'
|
||||
|
||||
import type { XTermRef } from '../xterm'
|
||||
import XTerm from '../xterm'
|
||||
import LogLevelSelect from './log_level_select'
|
||||
|
||||
const RealTimeLogs = () => {
|
||||
const Xterm = useRef<XTermRef>(null)
|
||||
const [logLevel, setLogLevel] = useState<Selection>(
|
||||
new Set(['info', 'warn', 'error'])
|
||||
)
|
||||
const [dataArr, setDataArr] = useState<Log[]>([])
|
||||
|
||||
const onDownloadLog = () => {
|
||||
const logContent = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n')
|
||||
const blob = new Blob([logContent], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'napcat.log'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const writeStream = () => {
|
||||
try {
|
||||
const _data = dataArr
|
||||
.filter((log) => {
|
||||
if (logLevel === 'all') {
|
||||
return true
|
||||
}
|
||||
return logLevel.has(log.level)
|
||||
})
|
||||
.map((log) => colorizeLogLevelWithTag(log.message, log.level))
|
||||
.join('\r\n')
|
||||
Xterm.current?.clear()
|
||||
Xterm.current?.write(_data)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('获取实时日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
writeStream()
|
||||
}, [logLevel, dataArr])
|
||||
|
||||
useEffect(() => {
|
||||
const subscribeLogs = () => {
|
||||
try {
|
||||
const source = WebUIManager.getRealTimeLogs((data) => {
|
||||
setDataArr((prev) => {
|
||||
const newData = [...prev, ...data]
|
||||
if (newData.length > 1000) {
|
||||
newData.splice(0, newData.length - 1000)
|
||||
}
|
||||
return newData
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
source.close()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('获取实时日志失败')
|
||||
}
|
||||
}
|
||||
|
||||
const close = subscribeLogs()
|
||||
return () => {
|
||||
console.log('close')
|
||||
close?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>实时日志 - NapCat WebUI</title>
|
||||
<div className="flex items-center gap-2">
|
||||
<LogLevelSelect
|
||||
selectedKeys={logLevel}
|
||||
onSelectionChange={setLogLevel}
|
||||
/>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
onPress={onDownloadLog}
|
||||
startContent={<IoDownloadOutline className="text-lg" />}
|
||||
>
|
||||
下载日志
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 h-full overflow-hidden">
|
||||
<XTerm ref={Xterm} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RealTimeLogs
|
||||
@@ -1,242 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { Snippet } from '@heroui/snippet'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { IoLink, IoSend } from 'react-icons/io5'
|
||||
import { PiCatDuotone } from 'react-icons/pi'
|
||||
|
||||
import key from '@/const/key'
|
||||
import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { parseAxiosResponse } from '@/utils/url'
|
||||
import { generateDefaultJson, parse } from '@/utils/zod'
|
||||
|
||||
import DisplayStruct from './display_struct'
|
||||
|
||||
export interface OneBotApiDebugProps {
|
||||
path: OneBotHttpApiPath
|
||||
data: OneBotHttpApiContent
|
||||
}
|
||||
|
||||
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const { path, data } = props
|
||||
const currentURL = new URL(window.location.origin)
|
||||
currentURL.port = '3000'
|
||||
const defaultHttpUrl = currentURL.href
|
||||
const [httpConfig, setHttpConfig] = useLocalStorage(key.httpDebugConfig, {
|
||||
url: defaultHttpUrl,
|
||||
token: ''
|
||||
})
|
||||
const [requestBody, setRequestBody] = useState('{}')
|
||||
const [responseContent, setResponseContent] = useState('')
|
||||
const [isCodeEditorOpen, setIsCodeEditorOpen] = useState(false)
|
||||
const [isResponseOpen, setIsResponseOpen] = useState(false)
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const responseRef = useRef<HTMLDivElement>(null)
|
||||
const parsedRequest = parse(data.request)
|
||||
const parsedResponse = parse(data.response)
|
||||
|
||||
const sendRequest = async () => {
|
||||
if (isFetching) return
|
||||
setIsFetching(true)
|
||||
const r = toast.loading('正在发送请求...')
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody)
|
||||
const requestURL = new URL(httpConfig.url)
|
||||
requestURL.pathname = path
|
||||
request
|
||||
.post(requestURL.href, parsedRequestBody, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${httpConfig.token}`
|
||||
},
|
||||
responseType: 'text'
|
||||
})
|
||||
.then((res) => {
|
||||
setResponseContent(parseAxiosResponse(res))
|
||||
toast.success('请求发送完成,请查看响应')
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('请求发送失败:' + err.message)
|
||||
setResponseContent(parseAxiosResponse(err.response))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetching(false)
|
||||
setIsResponseOpen(true)
|
||||
responseRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
})
|
||||
toast.dismiss(r)
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('请求体 JSON 格式错误')
|
||||
setIsFetching(false)
|
||||
toast.dismiss(r)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setRequestBody(generateDefaultJson(data.request))
|
||||
setResponseContent('')
|
||||
}, [path])
|
||||
|
||||
return (
|
||||
<section className="p-4 pt-14 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4 flex items-center gap-1 text-primary-400">
|
||||
<PiCatDuotone />
|
||||
{data.description}
|
||||
</h1>
|
||||
<h1 className="text-lg font-bold mb-4">
|
||||
<Snippet
|
||||
className="bg-default-50 bg-opacity-50 backdrop-blur-md"
|
||||
symbol={<IoLink size={18} className="inline-block mr-1" />}
|
||||
tooltipProps={{
|
||||
content: '点击复制地址'
|
||||
}}
|
||||
>
|
||||
{path}
|
||||
</Snippet>
|
||||
</h1>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
label="HTTP URL"
|
||||
placeholder="输入 HTTP URL"
|
||||
value={httpConfig.url}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Token"
|
||||
placeholder="输入 Token"
|
||||
value={httpConfig.token}
|
||||
onChange={(e) =>
|
||||
setHttpConfig({ ...httpConfig, token: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onPress={sendRequest}
|
||||
color="primary"
|
||||
size="lg"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
isDisabled={isFetching}
|
||||
>
|
||||
<IoSend />
|
||||
</Button>
|
||||
</div>
|
||||
<Card
|
||||
shadow="sm"
|
||||
className="my-4 bg-opacity-50 backdrop-blur-md overflow-visible"
|
||||
>
|
||||
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">请求体</span>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={() => setIsCodeEditorOpen(!isCodeEditorOpen)}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isCodeEditorOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
ref={responseRef}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isCodeEditorOpen ? 1 : 0,
|
||||
height: isCodeEditorOpen ? 'auto' : 0
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
value={requestBody}
|
||||
onChange={(value) => setRequestBody(value ?? '')}
|
||||
language="json"
|
||||
height="400px"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-1">
|
||||
<ChatInputModal />
|
||||
<Button
|
||||
color="primary"
|
||||
variant="flat"
|
||||
onPress={() =>
|
||||
setRequestBody(generateDefaultJson(data.request))
|
||||
}
|
||||
>
|
||||
填充示例请求体
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card
|
||||
shadow="sm"
|
||||
className="my-4 relative bg-opacity-50 backdrop-blur-md"
|
||||
>
|
||||
<PageLoading loading={isFetching} />
|
||||
<CardHeader className="font-bold text-lg gap-1 pb-0">
|
||||
<span className="mr-2">响应</span>
|
||||
<Button
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={() => setIsResponseOpen(!isResponseOpen)}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isResponseOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
<Button
|
||||
color="success"
|
||||
variant="flat"
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(responseContent)
|
||||
toast.success('响应内容已复制到剪贴板')
|
||||
}}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<motion.div
|
||||
className="overflow-y-auto text-sm"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: isResponseOpen ? 1 : 0,
|
||||
height: isResponseOpen ? 300 : 0
|
||||
}}
|
||||
>
|
||||
<pre>
|
||||
<code>
|
||||
{responseContent || (
|
||||
<div className="text-gray-400">暂无响应</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</motion.div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="p-2 md:p-4 border border-default-50 dark:border-default-200 rounded-lg backdrop-blur-sm">
|
||||
<h2 className="text-xl font-semibold mb-2">请求体结构</h2>
|
||||
<DisplayStruct schema={parsedRequest} />
|
||||
<h2 className="text-xl font-semibold mt-4 mb-2">响应体结构</h2>
|
||||
<DisplayStruct schema={parsedResponse} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotApiDebug
|
||||
@@ -1,202 +0,0 @@
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { motion } from 'motion/react'
|
||||
import React, { useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { TbSquareRoundedChevronRightFilled } from 'react-icons/tb'
|
||||
|
||||
import type { LiteralValue, ParsedSchema } from '@/utils/zod'
|
||||
|
||||
interface DisplayStructProps {
|
||||
schema: ParsedSchema | ParsedSchema[]
|
||||
}
|
||||
|
||||
const SchemaType = ({
|
||||
type,
|
||||
value
|
||||
}: {
|
||||
type: string
|
||||
value?: LiteralValue
|
||||
}) => {
|
||||
let name = type
|
||||
switch (type) {
|
||||
case 'union':
|
||||
name = '联合类型'
|
||||
break
|
||||
case 'value':
|
||||
name = '固定值'
|
||||
break
|
||||
}
|
||||
let chipColor: 'primary' | 'success' | 'primary' | 'warning' | 'secondary' =
|
||||
'primary'
|
||||
switch (type) {
|
||||
case 'enum':
|
||||
chipColor = 'warning'
|
||||
break
|
||||
case 'union':
|
||||
chipColor = 'secondary'
|
||||
break
|
||||
case 'array':
|
||||
chipColor = 'primary'
|
||||
break
|
||||
case 'object':
|
||||
chipColor = 'success'
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip size="sm" color={chipColor} variant="flat">
|
||||
{name}
|
||||
{type === 'value' && (
|
||||
<span className="px-1 rounded-full bg-primary-400 text-white ml-1">
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
|
||||
const SchemaLabel: React.FC<{
|
||||
schema: ParsedSchema
|
||||
}> = ({ schema }) => (
|
||||
<>
|
||||
{Array.isArray(schema.type) ? (
|
||||
schema.type.map((type) => (
|
||||
<SchemaType key={type} type={type} value={schema?.value} />
|
||||
))
|
||||
) : (
|
||||
<SchemaType type={schema.type} value={schema?.value} />
|
||||
)}
|
||||
{schema.optional && (
|
||||
<Chip size="sm" color="default" variant="flat">
|
||||
可选
|
||||
</Chip>
|
||||
)}
|
||||
{schema.description && (
|
||||
<span className="text-xs text-default-400">{schema.description}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const SchemaContainer: React.FC<{
|
||||
schema: ParsedSchema
|
||||
children: React.ReactNode
|
||||
}> = ({ schema, children }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const toggleExpand = () => setExpanded(!expanded)
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
onClick={toggleExpand}
|
||||
className="md:cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ rotate: 0 }}
|
||||
animate={{ rotate: expanded ? 90 : 0 }}
|
||||
>
|
||||
<TbSquareRoundedChevronRightFilled />
|
||||
</motion.div>
|
||||
<Tooltip content="点击复制" placement="top" showArrow>
|
||||
<span
|
||||
className="border-b border-transparent border-dashed hover:border-primary-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(schema.name || '')
|
||||
toast.success('已复制')
|
||||
}}
|
||||
>
|
||||
{schema.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SchemaLabel schema={schema} />
|
||||
</div>
|
||||
<motion.div
|
||||
className="ml-5 overflow-hidden"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: expanded ? 1 : 0, height: expanded ? 'auto' : 0 }}
|
||||
>
|
||||
<div className="h-2"></div>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema }> = ({ schema }) => {
|
||||
if (schema.type === 'object') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
{schema.children && schema.children.length > 0 ? (
|
||||
schema.children.map((child, i) => (
|
||||
<RenderSchema key={child.name || i} schema={child} />
|
||||
))
|
||||
) : (
|
||||
<div>{`{}`}</div>
|
||||
)}
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (schema.type === 'array' || schema.type === 'union') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
{schema.children?.map((child, i) => (
|
||||
<RenderSchema key={child.name || i} schema={child} />
|
||||
))}
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (schema.type === 'enum' && Array.isArray(schema.enum)) {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
<div className="flex gap-1 items-center">
|
||||
{schema.enum?.map((value, i) => (
|
||||
<Chip
|
||||
key={value?.toString() || i}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="success"
|
||||
>
|
||||
{value?.toString()}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</SchemaContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-1 pl-5">
|
||||
<Tooltip content="点击复制" placement="top" showArrow>
|
||||
<span
|
||||
className="border-b border-transparent border-dashed hover:border-primary-400 md:cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(schema.name || '')
|
||||
toast.success('已复制')
|
||||
}}
|
||||
>
|
||||
{schema.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SchemaLabel schema={schema} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DisplayStruct: React.FC<DisplayStructProps> = ({ schema }) => {
|
||||
return (
|
||||
<div className="p-4 bg-content2 rounded-lg bg-opacity-50">
|
||||
{Array.isArray(schema) ? (
|
||||
schema.map((s, i) => <RenderSchema key={s.name || i} schema={s} />)
|
||||
) : (
|
||||
<RenderSchema schema={schema} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DisplayStruct
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { VariableSizeList } from 'react-window'
|
||||
|
||||
import OneBotItemRender, {
|
||||
getItemSize
|
||||
} from '@/components/onebot/display_card/render'
|
||||
|
||||
import { isOB11Event } from '@/utils/onebot'
|
||||
|
||||
import type { AllOB11WsResponse } from '@/types/onebot'
|
||||
|
||||
export interface OneBotMessageListProps {
|
||||
messages: AllOB11WsResponse[]
|
||||
}
|
||||
|
||||
const OneBotMessageList: React.FC<OneBotMessageListProps> = (props) => {
|
||||
const { messages } = props
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<VariableSizeList>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (containerRef.current) {
|
||||
setContainerHeight(containerRef.current.offsetHeight)
|
||||
}
|
||||
})
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.resetAfterIndex(0, true)
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden" ref={containerRef}>
|
||||
<VariableSizeList
|
||||
ref={listRef}
|
||||
itemCount={messages.length}
|
||||
width="100%"
|
||||
style={{
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
itemSize={(idx) => {
|
||||
const msg = messages[idx]
|
||||
if (isOB11Event(msg)) {
|
||||
const size = getItemSize(msg.post_type)
|
||||
return size
|
||||
} else {
|
||||
return 100
|
||||
}
|
||||
}}
|
||||
height={containerHeight}
|
||||
itemData={messages}
|
||||
itemKey={(index) => messages.length - index - 1}
|
||||
>
|
||||
{OneBotItemRender}
|
||||
</VariableSizeList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotMessageList
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure
|
||||
} from '@heroui/modal'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import ChatInputModal from '@/components/chat_input/modal'
|
||||
import CodeEditor from '@/components/code_editor'
|
||||
import type { CodeEditorRef } from '@/components/code_editor'
|
||||
|
||||
export interface OneBotSendModalProps {
|
||||
sendMessage: (msg: string) => void
|
||||
}
|
||||
|
||||
const OneBotSendModal: React.FC<OneBotSendModalProps> = (props) => {
|
||||
const { sendMessage } = props
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure()
|
||||
const editorRef = useRef<CodeEditorRef | null>(null)
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(onClose: () => void) => {
|
||||
const msg = editorRef.current?.getValue()
|
||||
if (!msg) {
|
||||
toast.error('消息不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
sendMessage(msg)
|
||||
toast.success('消息发送成功')
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error('消息发送失败')
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={onOpen} color="primary" radius="full" variant="flat">
|
||||
构造请求
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
placement="top-center"
|
||||
size="5xl"
|
||||
scrollBehavior="outside"
|
||||
isDismissable={false}
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
构造请求
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="h-96 dark:bg-[rgb(30,30,30)] p-2 rounded-md border border-default-100">
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
defaultLanguage="json"
|
||||
defaultValue={`{
|
||||
"action": "get_group_list"
|
||||
}`}
|
||||
ref={editorRef}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ChatInputModal />
|
||||
|
||||
<Button color="primary" variant="flat" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={() => handleSendMessage(onClose)}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default OneBotSendModal
|
||||
@@ -1,39 +0,0 @@
|
||||
import clsx from 'clsx'
|
||||
import { ReadyState } from 'react-use-websocket'
|
||||
|
||||
export interface WSStatusProps {
|
||||
state: ReadyState
|
||||
}
|
||||
|
||||
function StatusTag({
|
||||
title,
|
||||
color
|
||||
}: {
|
||||
title: string
|
||||
color: 'success' | 'primary' | 'warning'
|
||||
}) {
|
||||
const textClassName = `text-${color} text-sm`
|
||||
const bgClassName = `bg-${color}`
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center gap-1 rounded-md px-2 col-span-2 md:col-span-1">
|
||||
<div className={clsx('w-4 h-4 rounded-full', bgClassName)}></div>
|
||||
<div className={textClassName}>{title}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WSStatus({ state }: WSStatusProps) {
|
||||
if (state === ReadyState.OPEN) {
|
||||
return <StatusTag title="已连接" color="success" />
|
||||
}
|
||||
if (state === ReadyState.CLOSED) {
|
||||
return <StatusTag title="已关闭" color="primary" />
|
||||
}
|
||||
if (state === ReadyState.CONNECTING) {
|
||||
return <StatusTag title="连接中" color="warning" />
|
||||
}
|
||||
if (state === ReadyState.CLOSING) {
|
||||
return <StatusTag title="关闭中" color="warning" />
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Image } from '@heroui/image'
|
||||
import React from 'react'
|
||||
|
||||
import bkg_color from '@/assets/images/bkg-color.png'
|
||||
|
||||
const PageBackground = () => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="fixed w-full h-full -z-[0] flex justify-end opacity-80">
|
||||
<Image
|
||||
className="overflow-hidden object-contain -top-42 h-[160%] -right-[30%] -rotate-45 pointer-events-none select-none -z-10 relative"
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
<div className="fixed w-full h-full overflow-hidden -z-[0] hue-rotate-90 flex justify-start opacity-80">
|
||||
<Image
|
||||
className="relative -top-92 h-[180%] object-contain pointer-events-none rotate-90 select-none -z-10 top-44"
|
||||
src={bkg_color}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageBackground
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import clsx from 'clsx'
|
||||
import { BsTencentQq } from 'react-icons/bs'
|
||||
|
||||
import { SelfInfo } from '@/types/user'
|
||||
|
||||
import PageLoading from './page_loading'
|
||||
|
||||
export interface QQInfoCardProps {
|
||||
data?: SelfInfo
|
||||
error?: Error
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const QQInfoCard: React.FC<QQInfoCardProps> = ({ data, error, loading }) => {
|
||||
return (
|
||||
<Card
|
||||
className="relative bg-primary-100 bg-opacity-60 overflow-hidden flex-shrink-0 shadow-md shadow-primary-300 dark:shadow-primary-50"
|
||||
shadow="none"
|
||||
radius="lg"
|
||||
>
|
||||
<PageLoading loading={loading} />
|
||||
{error ? (
|
||||
<CardBody className="items-center gap-1 justify-center">
|
||||
<div className="flex-1 text-content1-foreground">Error</div>
|
||||
<div className="whitespace-nowrap text-nowrap flex-shrink-0">
|
||||
{error.message}
|
||||
</div>
|
||||
</CardBody>
|
||||
) : (
|
||||
<CardBody className="flex-row items-center gap-2 overflow-hidden relative">
|
||||
<div className="absolute right-0 bottom-0 text-5xl text-primary-400">
|
||||
<BsTencentQq />
|
||||
</div>
|
||||
<div className="relative flex-shrink-0 z-10">
|
||||
<Image
|
||||
src={
|
||||
data?.avatarUrl ??
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${data?.uin}&s=1`
|
||||
}
|
||||
className="shadow-md rounded-full w-12 aspect-square"
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-4 h-4 rounded-full absolute right-0.5 bottom-0 border-2 border-primary-100 z-10',
|
||||
data?.online ? 'bg-green-500' : 'bg-gray-500'
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex-col justify-center">
|
||||
<div className="text-lg truncate">{data?.nick}</div>
|
||||
<div className="text-primary-500 text-sm">{data?.uin}</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default QQInfoCard
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
|
||||
interface QrCodeLoginProps {
|
||||
qrcode: string
|
||||
}
|
||||
|
||||
const QrCodeLogin: React.FC<QrCodeLoginProps> = ({ qrcode }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="bg-white p-2 rounded-md w-fit mx-auto relative overflow-hidden">
|
||||
{!qrcode && (
|
||||
<div className="absolute left-2 top-2 right-2 bottom-2 bg-white bg-opacity-50 backdrop-blur flex items-center justify-center">
|
||||
<Spinner color="primary" />
|
||||
</div>
|
||||
)}
|
||||
<QRCodeSVG size={180} value={qrcode} />
|
||||
</div>
|
||||
<div className="mt-5 text-center">请使用QQ或者TIM扫描上方二维码</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QrCodeLogin
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Image } from '@heroui/image'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
import { matchPath, useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import type { MenuItem } from '@/config/site'
|
||||
|
||||
const renderItems = (items: MenuItem[], children = false) => {
|
||||
return items?.map((item) => {
|
||||
const navigate = useNavigate()
|
||||
const locate = useLocation()
|
||||
const [open, setOpen] = React.useState(!!item.autoOpen)
|
||||
const canOpen = React.useMemo(
|
||||
() => item.items && item.items.length > 0,
|
||||
[item.items]
|
||||
)
|
||||
const [b64img] = useLocalStorage(key.backgroundImage, '')
|
||||
const [customIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
)
|
||||
const isActive = React.useMemo(() => {
|
||||
if (item.href) {
|
||||
return !!matchPath(item.href, locate.pathname)
|
||||
}
|
||||
|
||||
return false
|
||||
}, [item.href, locate.pathname])
|
||||
|
||||
const goTo = (href: string) => {
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (item.items) {
|
||||
const shouldOpen = item.items.some(
|
||||
(item) => item?.href && !!matchPath(item.href, locate.pathname)
|
||||
)
|
||||
|
||||
if (shouldOpen) setOpen(true)
|
||||
}
|
||||
}, [item.items, locate.pathname])
|
||||
const panelRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div key={item.href + item.label}>
|
||||
<Button
|
||||
className={clsx(
|
||||
'flex items-center w-full text-left justify-start dark:text-white',
|
||||
// children && 'rounded-l-lg',
|
||||
isActive && 'bg-opacity-60',
|
||||
b64img && 'backdrop-blur-md text-white'
|
||||
)}
|
||||
color="primary"
|
||||
endContent={
|
||||
canOpen ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'ml-auto relative w-3 h-3 transition-transform',
|
||||
open && 'transform rotate-180',
|
||||
isActive
|
||||
? 'text-primary-500'
|
||||
: 'text-primary-200 dark:text-white',
|
||||
'before:rounded-full',
|
||||
'before:content-[""]',
|
||||
'before:block',
|
||||
'before:absolute',
|
||||
'before:w-3',
|
||||
'before:h-[4.5px]',
|
||||
'before:bg-current',
|
||||
'before:top-1/2',
|
||||
'before:-left-[3px]',
|
||||
'before:transform',
|
||||
'before:-translate-y-1/2',
|
||||
'before:rotate-45',
|
||||
'after:rounded-full',
|
||||
'after:content-[""]',
|
||||
'after:block',
|
||||
'after:absolute',
|
||||
'after:w-3',
|
||||
'after:h-[4.5px]',
|
||||
'after:bg-current',
|
||||
'after:top-1/2',
|
||||
'after:left-[3px]',
|
||||
'after:transform',
|
||||
'after:-translate-y-1/2',
|
||||
'after:-rotate-45'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-3 h-1.5 rounded-full ml-auto shadow-lg',
|
||||
isActive
|
||||
? 'bg-primary-500 animate-spinner-ease-spin'
|
||||
: 'bg-primary-200 dark:bg-white'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
radius="full"
|
||||
startContent={
|
||||
customIcons[item.label] ? (
|
||||
<Image
|
||||
radius="none"
|
||||
src={customIcons[item.label]}
|
||||
alt={item.label}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
) : (
|
||||
item.icon
|
||||
)
|
||||
}
|
||||
variant={isActive ? (children ? 'solid' : 'shadow') : 'light'}
|
||||
onPress={() => {
|
||||
if (item.href) {
|
||||
if (!isActive) {
|
||||
goTo(item.href)
|
||||
}
|
||||
} else if (canOpen) {
|
||||
setOpen(!open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="ml-4 overflow-hidden transition-all duration-300"
|
||||
style={{
|
||||
height: open ? panelRef.current?.scrollHeight : 0
|
||||
}}
|
||||
>
|
||||
{item.items && renderItems(item.items, true)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
interface MenusProps {
|
||||
items: MenuItem[]
|
||||
}
|
||||
const Menus: React.FC<MenusProps> = (props) => {
|
||||
const { items } = props
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-content-center flex-1 gap-2">
|
||||
{renderItems(items)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Menus
|
||||
@@ -1,274 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card'
|
||||
import { Chip } from '@heroui/chip'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { useEffect } from 'react'
|
||||
import { BsStars } from 'react-icons/bs'
|
||||
import { FaCircleInfo, FaInfo, FaQq } from 'react-icons/fa6'
|
||||
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io'
|
||||
import { RiMacFill } from 'react-icons/ri'
|
||||
|
||||
import useDialog from '@/hooks/use-dialog'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { compareVersion } from '@/utils/version'
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
import { GithubRelease } from '@/types/github'
|
||||
|
||||
import TailwindMarkdown from './tailwind_markdown'
|
||||
|
||||
export interface SystemInfoItemProps {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
value?: React.ReactNode
|
||||
endContent?: React.ReactNode
|
||||
}
|
||||
|
||||
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
title,
|
||||
value = '--',
|
||||
icon,
|
||||
endContent
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex text-sm gap-1 p-2 items-center shadow-sm shadow-primary-100 dark:shadow-primary-100 rounded text-primary-400">
|
||||
{icon}
|
||||
<div className="w-24">{title}</div>
|
||||
<div className="text-primary-200">{value}</div>
|
||||
<div className="ml-auto">{endContent}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface NewVersionTipProps {
|
||||
currentVersion?: string
|
||||
}
|
||||
|
||||
const NewVersionTip = (props: NewVersionTipProps) => {
|
||||
const { currentVersion } = props
|
||||
const dialog = useDialog()
|
||||
const { data: releaseData, error } = useRequest(() =>
|
||||
request.get<GithubRelease[]>(
|
||||
'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
|
||||
)
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Tooltip content="检查新版本失败">
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="primary"
|
||||
variant="shadow"
|
||||
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||
onPress={() => {
|
||||
dialog.alert({
|
||||
title: '检查新版本失败',
|
||||
content: error.message
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const latestVersion = releaseData?.data?.[0]?.tag_name
|
||||
|
||||
if (!latestVersion || !currentVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (compareVersion(latestVersion, currentVersion) <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const middleVersions: GithubRelease[] = []
|
||||
|
||||
for (let i = 0; i < releaseData.data.length; i++) {
|
||||
const versionInfo = releaseData.data[i]
|
||||
if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
|
||||
middleVersions.push(versionInfo)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const AISummaryComponent = () => {
|
||||
const {
|
||||
data: aiSummaryData,
|
||||
loading: aiSummaryLoading,
|
||||
error: aiSummaryError,
|
||||
run: runAiSummary
|
||||
} = useRequest(
|
||||
(version) =>
|
||||
request.get<ServerResponse<string | null>>(
|
||||
`https://release.nc.152710.xyz/?version=${version}`,
|
||||
{
|
||||
timeout: 30000
|
||||
}
|
||||
),
|
||||
{
|
||||
manual: true
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
runAiSummary(currentVersion)
|
||||
}, [currentVersion, runAiSummary])
|
||||
|
||||
if (aiSummaryLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-1">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (aiSummaryError) {
|
||||
return <div className="text-center text-primary-500">AI 摘要获取失败</div>
|
||||
}
|
||||
return <span className="text-default-700">{aiSummaryData?.data.data}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="有新版本可用">
|
||||
<Button
|
||||
isIconOnly
|
||||
radius="full"
|
||||
color="primary"
|
||||
variant="shadow"
|
||||
className="!w-5 !h-5 !min-w-0 text-small shadow-md"
|
||||
onPress={() => {
|
||||
dialog.confirm({
|
||||
title: '有新版本可用',
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm space-x-2">
|
||||
<span>当前版本</span>
|
||||
<Chip color="primary" variant="flat">
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="text-sm space-x-2">
|
||||
<span>最新版本</span>
|
||||
<Chip color="primary">{latestVersion}</Chip>
|
||||
</div>
|
||||
<div className="p-2 rounded-md bg-content2 text-sm">
|
||||
<div className="text-primary-400 font-bold flex items-center gap-1 mb-1">
|
||||
<BsStars />
|
||||
<span>AI总结</span>
|
||||
</div>
|
||||
{<AISummaryComponent />}
|
||||
</div>
|
||||
<div className="text-sm space-y-2 !mt-4">
|
||||
{middleVersions.map((versionInfo) => (
|
||||
<div
|
||||
key={versionInfo.tag_name}
|
||||
className="p-4 bg-content1 rounded-md shadow-small"
|
||||
>
|
||||
<TailwindMarkdown content={versionInfo.body} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
scrollBehavior: 'inside',
|
||||
size: '3xl',
|
||||
confirmText: '前往下载',
|
||||
onConfirm() {
|
||||
window.open(
|
||||
'https://github.com/NapNeko/NapCatQQ/releases',
|
||||
'_blank',
|
||||
'noopener'
|
||||
)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FaInfo />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const NapCatVersion = () => {
|
||||
const {
|
||||
data: packageData,
|
||||
loading: packageLoading,
|
||||
error: packageError
|
||||
} = useRequest(WebUIManager.getPackageInfo)
|
||||
|
||||
const currentVersion = packageData?.version
|
||||
|
||||
return (
|
||||
<SystemInfoItem
|
||||
title="NapCat 版本"
|
||||
icon={<IoLogoOctocat className="text-xl" />}
|
||||
value={
|
||||
packageError ? (
|
||||
`错误:${packageError.message}`
|
||||
) : packageLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
currentVersion
|
||||
)
|
||||
}
|
||||
endContent={<NewVersionTip currentVersion={currentVersion} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SystemInfoProps {
|
||||
archInfo?: string
|
||||
}
|
||||
const SystemInfo: React.FC<SystemInfoProps> = (props) => {
|
||||
const { archInfo } = props
|
||||
const {
|
||||
data: qqVersionData,
|
||||
loading: qqVersionLoading,
|
||||
error: qqVersionError
|
||||
} = useRequest(WebUIManager.getQQVersion)
|
||||
return (
|
||||
<Card className="bg-opacity-60 shadow-sm shadow-primary-100 dark:shadow-primary-100 overflow-visible flex-1">
|
||||
<CardHeader className="pb-0 items-center gap-1 text-primary-500 font-extrabold">
|
||||
<FaCircleInfo className="text-lg" />
|
||||
<span>系统信息</span>
|
||||
</CardHeader>
|
||||
<CardBody className="flex-1">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<NapCatVersion />
|
||||
<SystemInfoItem
|
||||
title="QQ 版本"
|
||||
icon={<FaQq className="text-lg" />}
|
||||
value={
|
||||
qqVersionError ? (
|
||||
`错误:${qqVersionError.message}`
|
||||
) : qqVersionLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
qqVersionData
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SystemInfoItem
|
||||
title="WebUI 版本"
|
||||
icon={<IoLogoChrome className="text-xl" />}
|
||||
value="Next"
|
||||
/>
|
||||
<SystemInfoItem
|
||||
title="系统版本"
|
||||
icon={<RiMacFill className="text-xl" />}
|
||||
value={archInfo}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemInfo
|
||||
@@ -1,130 +0,0 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import clsx from 'clsx'
|
||||
import { BiSolidMemoryCard } from 'react-icons/bi'
|
||||
import { GiCpu } from 'react-icons/gi'
|
||||
|
||||
import bkg from '@/assets/images/bg/1AD934174C0107F14BAD8776D29C5F90.png'
|
||||
|
||||
import UsagePie from './usage_pie'
|
||||
|
||||
export interface SystemStatusItemProps {
|
||||
title: string
|
||||
value?: string | number
|
||||
size?: 'md' | 'lg'
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
title,
|
||||
value = '-',
|
||||
size = 'md',
|
||||
unit
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'shadow-sm shadow-primary-100 p-2 rounded-md text-sm bg-content1 bg-opacity-30',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between'
|
||||
)}
|
||||
>
|
||||
<div className="w-24">{title}</div>
|
||||
<div className="text-default-400">
|
||||
{value}
|
||||
{unit}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SystemStatusDisplayProps {
|
||||
data?: SystemStatus
|
||||
}
|
||||
|
||||
const SystemStatusDisplay: React.FC<SystemStatusDisplayProps> = ({ data }) => {
|
||||
const memoryUsage = {
|
||||
system: 0,
|
||||
qq: 0
|
||||
}
|
||||
if (data) {
|
||||
const system = Number(data.memory.total) || 1
|
||||
const systemUsage = Number(data.memory.usage.system)
|
||||
const qqUsage = Number(data.memory.usage.qq)
|
||||
memoryUsage.system = (systemUsage / system) * 100
|
||||
memoryUsage.qq = (qqUsage / system) * 100
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-opacity-60 shadow-sm shadow-primary-100 col-span-1 lg:col-span-2 relative overflow-hidden">
|
||||
<div className="absolute h-full right-0 top-0">
|
||||
<Image
|
||||
src={bkg}
|
||||
alt="background"
|
||||
className="select-none pointer-events-none !opacity-30 w-full h-full"
|
||||
classNames={{
|
||||
wrapper: 'w-full h-full',
|
||||
img: 'object-contain w-full h-full'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CardBody className="overflow-visible md:flex-row gap-4 items-center justify-stretch z-10">
|
||||
<div className="flex-1 w-full md:max-w-96">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-1 text-primary-400">
|
||||
<GiCpu className="text-xl" />
|
||||
<span>CPU</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SystemStatusItem title="型号" value={data?.cpu.model} size="lg" />
|
||||
<SystemStatusItem title="内核数" value={data?.cpu.core} />
|
||||
<SystemStatusItem title="主频" value={data?.cpu.speed} unit="GHz" />
|
||||
<SystemStatusItem
|
||||
title="使用率"
|
||||
value={data?.cpu.usage.system}
|
||||
unit="%"
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title="QQ主线程"
|
||||
value={data?.cpu.usage.qq}
|
||||
unit="%"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-1 text-primary-400 mt-2">
|
||||
<BiSolidMemoryCard className="text-xl" />
|
||||
<span>内存</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SystemStatusItem
|
||||
title="总量"
|
||||
value={data?.memory.total}
|
||||
size="lg"
|
||||
unit="MB"
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title="使用量"
|
||||
value={data?.memory.usage.system}
|
||||
unit="MB"
|
||||
/>
|
||||
<SystemStatusItem
|
||||
title="QQ主线程"
|
||||
value={data?.memory.usage.qq}
|
||||
unit="MB"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row md:flex-col gap-2 flex-shrink-0 w-full justify-center md:w-40 min-h-40 mt-4 md:mt-0 md:mx-auto">
|
||||
<UsagePie
|
||||
systemUsage={Number(data?.cpu.usage.system) || 0}
|
||||
processUsage={Number(data?.cpu.usage.qq) || 0}
|
||||
title="CPU占用"
|
||||
/>
|
||||
<UsagePie
|
||||
systemUsage={memoryUsage.system}
|
||||
processUsage={memoryUsage.qq}
|
||||
title="内存占用"
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
export default SystemStatusDisplay
|
||||
@@ -1,49 +0,0 @@
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const TailwindMarkdown: React.FC<{ content: string }> = ({ content }) => {
|
||||
return (
|
||||
<Markdown
|
||||
className="prose prose-sm sm:prose lg:prose-lg xl:prose-xl"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-2xl font-bold" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="text-xl font-bold" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="text-lg font-bold" {...props} />
|
||||
),
|
||||
p: ({ node, ...props }) => <p className="m-0" {...props} />,
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
className="text-primary-500 inline-block hover:underline"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="list-disc list-inside" {...props} />
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="list-decimal list-inside" {...props} />
|
||||
),
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-default-300 pl-4 italic"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node, ...props }) => (
|
||||
<code className="bg-default-100 p-1 rounded text-xs" {...props} />
|
||||
)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default TailwindMarkdown
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import TerminalManager from '@/controllers/terminal_manager'
|
||||
|
||||
import XTerm, { XTermRef } from '../xterm'
|
||||
|
||||
interface TerminalInstanceProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
export function TerminalInstance({ id }: TerminalInstanceProps) {
|
||||
const termRef = useRef<XTermRef>(null)
|
||||
const connected = useRef(false)
|
||||
|
||||
const handleData = (data: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.data) {
|
||||
termRef.current?.write(parsed.data)
|
||||
}
|
||||
} catch (e) {
|
||||
termRef.current?.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (connected.current) {
|
||||
TerminalManager.disconnectTerminal(id, handleData)
|
||||
}
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const handleInput = (data: string) => {
|
||||
TerminalManager.sendInput(id, data)
|
||||
}
|
||||
|
||||
const handleResize = (cols: number, rows: number) => {
|
||||
if (!connected.current) {
|
||||
connected.current = true
|
||||
console.log('instance', rows, cols)
|
||||
TerminalManager.connectTerminal(id, handleData, { rows, cols })
|
||||
} else {
|
||||
TerminalManager.sendResize(id, cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<XTerm
|
||||
ref={termRef}
|
||||
onInput={handleInput}
|
||||
onResize={handleResize} // 使用 fitAddon 改变后触发的 resize 回调
|
||||
className="w-full h-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Toaster as HotToaster } from 'react-hot-toast'
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
export const Toaster = () => {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<HotToaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '20px',
|
||||
background: isDark ? '#333' : '#fff',
|
||||
color: isDark ? '#fff' : '#333'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toaster
|
||||
@@ -1,12 +0,0 @@
|
||||
export default function UnderConstruction() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full pt-4">
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<div className="text-6xl font-bold text-gray-500">🚧</div>
|
||||
<div className="text-2xl font-bold text-gray-500">
|
||||
Under Construction
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import heroui from './themes/heroui'
|
||||
import nc_pink from './themes/nc_pink'
|
||||
|
||||
const themes: ThemeInfo[] = [nc_pink, heroui]
|
||||
|
||||
export default themes
|
||||
@@ -1,221 +0,0 @@
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { serverRequest } from '@/utils/request'
|
||||
|
||||
export interface FileInfo {
|
||||
name: string
|
||||
isDirectory: boolean
|
||||
size: number
|
||||
mtime: Date
|
||||
}
|
||||
|
||||
export default class FileManager {
|
||||
public static async listFiles(path: string = '/') {
|
||||
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
|
||||
`/File/list?path=${encodeURIComponent(path)}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
// 新增:按目录获取
|
||||
public static async listDirectories(path: string = '/') {
|
||||
const { data } = await serverRequest.get<ServerResponse<FileInfo[]>>(
|
||||
`/File/list?path=${encodeURIComponent(path)}&onlyDirectory=true`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async createDirectory(path: string): Promise<boolean> {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/mkdir',
|
||||
{ path }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async delete(path: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/delete',
|
||||
{ path }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async readFile(path: string) {
|
||||
const { data } = await serverRequest.get<ServerResponse<string>>(
|
||||
`/File/read?path=${encodeURIComponent(path)}`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async writeFile(path: string, content: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/write',
|
||||
{ path, content }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async createFile(path: string): Promise<boolean> {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/create',
|
||||
{ path }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async batchDelete(paths: string[]) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/batchDelete',
|
||||
{ paths }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async rename(oldPath: string, newPath: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/rename',
|
||||
{ oldPath, newPath }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async move(sourcePath: string, targetPath: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/move',
|
||||
{ sourcePath, targetPath }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async batchMove(
|
||||
items: { sourcePath: string; targetPath: string }[]
|
||||
) {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/batchMove',
|
||||
{ items }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static download(path: string) {
|
||||
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
|
||||
toast
|
||||
.promise(
|
||||
serverRequest
|
||||
.post(downloadUrl, void 0, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
throw new Error('下载失败')
|
||||
}),
|
||||
{
|
||||
loading: '正在下载文件...',
|
||||
success: '下载成功',
|
||||
error: '下载失败'
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
let fileName = path.split('/').pop() || ''
|
||||
if (path.split('.').length === 1) {
|
||||
fileName += '.zip'
|
||||
}
|
||||
link.setAttribute('download', fileName)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
public static async batchDownload(paths: string[]) {
|
||||
const downloadUrl = `/File/batchDownload`
|
||||
toast
|
||||
.promise(
|
||||
serverRequest
|
||||
.post(
|
||||
downloadUrl,
|
||||
{ paths },
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
throw new Error('下载失败')
|
||||
}),
|
||||
{
|
||||
loading: '正在下载文件...',
|
||||
success: '下载成功',
|
||||
error: '下载失败'
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const fileName = 'files.zip'
|
||||
link.setAttribute('download', fileName)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
public static async downloadToURL(path: string) {
|
||||
const downloadUrl = `/File/download?path=${encodeURIComponent(path)}`
|
||||
const response = await serverRequest.post(downloadUrl, void 0, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
return window.URL.createObjectURL(new Blob([response.data]))
|
||||
}
|
||||
|
||||
public static async upload(path: string, files: File[]) {
|
||||
const formData = new FormData()
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
`/File/upload?path=${encodeURIComponent(path)}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async uploadWebUIFont(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/font/upload/webui',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
public static async deleteWebUIFont() {
|
||||
const { data } = await serverRequest.post<ServerResponse<boolean>>(
|
||||
'/File/font/delete/webui'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { serverRequest } from '@/utils/request'
|
||||
|
||||
type TerminalCallback = (data: string) => void
|
||||
|
||||
interface TerminalConnection {
|
||||
ws: WebSocket
|
||||
callbacks: Set<TerminalCallback>
|
||||
isConnected: boolean
|
||||
buffer: string[] // 添加缓存数组
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TerminalInfo {
|
||||
id: string
|
||||
}
|
||||
|
||||
class TerminalManager {
|
||||
private connections: Map<string, TerminalConnection> = new Map()
|
||||
private readonly MAX_BUFFER_SIZE = 1000 // 限制缓存大小
|
||||
|
||||
async createTerminal(cols: number, rows: number): Promise<TerminalSession> {
|
||||
const { data } = await serverRequest.post<ServerResponse<TerminalSession>>(
|
||||
'/Log/terminal/create',
|
||||
{ cols, rows }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
async closeTerminal(id: string): Promise<void> {
|
||||
await serverRequest.post(`/Log/terminal/${id}/close`)
|
||||
}
|
||||
|
||||
async getTerminalList(): Promise<TerminalInfo[]> {
|
||||
const { data } =
|
||||
await serverRequest.get<ServerResponse<TerminalInfo[]>>(
|
||||
'/Log/terminal/list'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
connectTerminal(
|
||||
id: string,
|
||||
callback: TerminalCallback,
|
||||
config?: {
|
||||
cols?: number
|
||||
rows?: number
|
||||
}
|
||||
): WebSocket {
|
||||
let conn = this.connections.get(id)
|
||||
const { cols = 80, rows = 24 } = config || {}
|
||||
if (!conn) {
|
||||
const url = new URL(window.location.href)
|
||||
url.protocol = url.protocol.replace('http', 'ws')
|
||||
url.pathname = `/api/ws/terminal`
|
||||
url.searchParams.set('id', id)
|
||||
const token = JSON.parse(localStorage.getItem('token') || '')
|
||||
if (!token) {
|
||||
throw new Error('No token found')
|
||||
}
|
||||
url.searchParams.set('token', token)
|
||||
const ws = new WebSocket(url.toString())
|
||||
conn = {
|
||||
ws,
|
||||
callbacks: new Set([callback]),
|
||||
isConnected: false,
|
||||
buffer: [] // 初始化缓存
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data
|
||||
// 保存到缓存
|
||||
conn?.buffer.push(data)
|
||||
if ((conn?.buffer.length ?? 0) > this.MAX_BUFFER_SIZE) {
|
||||
conn?.buffer.shift()
|
||||
}
|
||||
conn?.callbacks.forEach((cb) => cb(data))
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
if (conn) conn.isConnected = true
|
||||
this.sendResize(id, cols, rows)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (conn) conn.isConnected = false
|
||||
}
|
||||
|
||||
this.connections.set(id, conn)
|
||||
} else {
|
||||
conn.callbacks.add(callback)
|
||||
// 恢复历史内容
|
||||
conn.buffer.forEach((data) => callback(data))
|
||||
}
|
||||
|
||||
return conn.ws
|
||||
}
|
||||
|
||||
disconnectTerminal(id: string, callback: TerminalCallback) {
|
||||
const conn = this.connections.get(id)
|
||||
if (!conn) return
|
||||
|
||||
conn.callbacks.delete(callback)
|
||||
}
|
||||
|
||||
removeTerminal(id: string) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.close()
|
||||
}
|
||||
this.connections.delete(id)
|
||||
}
|
||||
|
||||
sendInput(id: string, data: string) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
}
|
||||
|
||||
sendResize(id: string, cols: number, rows: number) {
|
||||
const conn = this.connections.get(id)
|
||||
if (conn?.ws.readyState === WebSocket.OPEN) {
|
||||
conn.ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const terminalManager = new TerminalManager()
|
||||
|
||||
export default terminalManager
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
const useAuth = () => {
|
||||
const [token, setToken] = useLocalStorage<string>(key.token, '')
|
||||
|
||||
return {
|
||||
token,
|
||||
isAuth: !!token,
|
||||
revokeAuth: () => setToken('')
|
||||
}
|
||||
}
|
||||
|
||||
export default useAuth
|
||||
@@ -1,189 +0,0 @@
|
||||
import { updateConfig as storeUpdateConfig } from '@/store/modules/config'
|
||||
|
||||
import { deepClone } from '@/utils/object'
|
||||
|
||||
import QQManager from '@/controllers/qq_manager'
|
||||
|
||||
import { useAppDispatch, useAppSelector } from './use-store'
|
||||
|
||||
const useConfig = () => {
|
||||
const config = useAppSelector((state) => state.config.value)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const createNetworkConfig = async <T extends keyof OneBotConfig['network']>(
|
||||
key: T,
|
||||
value: OneBotConfig['network'][T][0]
|
||||
) => {
|
||||
const allNetworkNames = Object.keys(config.network).reduce((acc, key) => {
|
||||
const _key = key as keyof OneBotConfig['network']
|
||||
return acc.concat(config.network[_key].map((item) => item.name))
|
||||
}, [] as string[])
|
||||
|
||||
if (value.name && allNetworkNames.includes(value.name)) {
|
||||
throw new Error('已经存在相同的配置项名')
|
||||
}
|
||||
|
||||
const newConfig = deepClone(config)
|
||||
|
||||
;(newConfig.network[key] as (typeof value)[]).push(value)
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const updateNetworkConfig = async <T extends keyof OneBotConfig['network']>(
|
||||
key: T,
|
||||
value: OneBotConfig['network'][T][0]
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
const name = value.name
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项')
|
||||
}
|
||||
|
||||
newConfig.network[key][index] = value
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const deleteNetworkConfig = async <T extends keyof OneBotConfig['network']>(
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项')
|
||||
}
|
||||
|
||||
newConfig.network[key].splice(index, 1)
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const enableNetworkConfig = async <T extends keyof OneBotConfig['network']>(
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项')
|
||||
}
|
||||
|
||||
newConfig.network[key][index].enable = !newConfig.network[key][index].enable
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const enableDebugNetworkConfig = async <
|
||||
T extends keyof OneBotConfig['network']
|
||||
>(
|
||||
key: T,
|
||||
name: string
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
const index = newConfig.network[key].findIndex((item) => item.name === name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error('找不到对应的配置项')
|
||||
}
|
||||
|
||||
newConfig.network[key][index].debug = !newConfig.network[key][index].debug
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const updateSingleConfig = async <T extends keyof OneBotConfig>(
|
||||
key: T,
|
||||
value: OneBotConfig[T]
|
||||
) => {
|
||||
const newConfig = deepClone(config)
|
||||
|
||||
newConfig[key] = value
|
||||
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const updateConfig = async (newConfig: OneBotConfig) => {
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const refreshConfig = async () => {
|
||||
const newConfig = await QQManager.getOB11Config()
|
||||
|
||||
if (JSON.stringify(newConfig) === JSON.stringify(config)) {
|
||||
return config
|
||||
}
|
||||
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const mergeConfig = async (newConfig: OneBotConfig) => {
|
||||
const mergedConfig = deepClone(config)
|
||||
|
||||
Object.assign(mergedConfig, newConfig)
|
||||
|
||||
await QQManager.setOB11Config(mergedConfig)
|
||||
|
||||
dispatch(storeUpdateConfig(mergedConfig))
|
||||
|
||||
return mergedConfig
|
||||
}
|
||||
|
||||
const saveConfigWithoutNetwork = async (newConfig: OneBotConfig) => {
|
||||
newConfig.network = config.network
|
||||
await QQManager.setOB11Config(newConfig)
|
||||
dispatch(storeUpdateConfig(newConfig))
|
||||
return newConfig
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
createNetworkConfig,
|
||||
refreshConfig,
|
||||
updateConfig,
|
||||
updateSingleConfig,
|
||||
updateNetworkConfig,
|
||||
deleteNetworkConfig,
|
||||
enableNetworkConfig,
|
||||
enableDebugNetworkConfig,
|
||||
mergeConfig,
|
||||
saveConfigWithoutNetwork
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { DialogContext } from '@/contexts/dialog'
|
||||
|
||||
const useDialog = () => {
|
||||
const dialog = React.useContext(DialogContext)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
export default useDialog
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { AudioContext } from '@/contexts/songs'
|
||||
|
||||
const useMusic = () => {
|
||||
const music = React.useContext(AudioContext)
|
||||
|
||||
return music
|
||||
}
|
||||
|
||||
export default useMusic
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
// 全局图片缓存
|
||||
const imageCache = new Map<string, HTMLImageElement>()
|
||||
|
||||
export function usePreloadImages(urls: string[]) {
|
||||
const [loadedUrls, setLoadedUrls] = useState<Record<string, boolean>>({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const isMounted = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true
|
||||
|
||||
// 检查是否所有图片都已缓存
|
||||
const allCached = urls.every((url) => imageCache.has(url))
|
||||
if (allCached) {
|
||||
setLoadedUrls(urls.reduce((acc, url) => ({ ...acc, [url]: true }), {}))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
const loadedImages: Record<string, boolean> = {}
|
||||
let pendingCount = urls.length
|
||||
|
||||
urls.forEach((url) => {
|
||||
// 如果已经缓存,直接标记为已加载
|
||||
if (imageCache.has(url)) {
|
||||
loadedImages[url] = true
|
||||
pendingCount--
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
if (!isMounted.current) return
|
||||
loadedImages[url] = true
|
||||
imageCache.set(url, img)
|
||||
pendingCount--
|
||||
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
img.onerror = () => {
|
||||
if (!isMounted.current) return
|
||||
loadedImages[url] = false
|
||||
pendingCount--
|
||||
|
||||
if (pendingCount === 0) {
|
||||
setLoadedUrls(loadedImages)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted.current = false
|
||||
}
|
||||
}, [urls])
|
||||
|
||||
return { loadedUrls, isLoading }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// originally written by @imoaazahmed
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
const ThemeProps = {
|
||||
key: 'theme',
|
||||
light: 'light',
|
||||
dark: 'dark'
|
||||
} as const
|
||||
|
||||
type Theme = typeof ThemeProps.light | typeof ThemeProps.dark
|
||||
|
||||
export const useTheme = (defaultTheme?: Theme) => {
|
||||
const [theme, setTheme] = useLocalStorage<Theme>(ThemeProps.key, defaultTheme)
|
||||
|
||||
const isDark = useMemo(() => {
|
||||
return theme === ThemeProps.dark
|
||||
}, [theme])
|
||||
|
||||
const isLight = useMemo(() => {
|
||||
return theme === ThemeProps.light
|
||||
}, [theme])
|
||||
|
||||
const _setTheme = (theme: Theme) => {
|
||||
setTheme(theme)
|
||||
document.documentElement.classList.remove(ThemeProps.light, ThemeProps.dark)
|
||||
document.documentElement.classList.add(theme)
|
||||
}
|
||||
|
||||
const setLightTheme = () => _setTheme(ThemeProps.light)
|
||||
|
||||
const setDarkTheme = () => _setTheme(ThemeProps.dark)
|
||||
|
||||
const toggleTheme = () =>
|
||||
theme === ThemeProps.dark ? setLightTheme() : setDarkTheme()
|
||||
|
||||
useEffect(() => {
|
||||
_setTheme(theme)
|
||||
})
|
||||
|
||||
return { theme, isDark, isLight, setLightTheme, setDarkTheme, toggleTheme }
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { Selection } from '@react-types/shared'
|
||||
import { useReactive } from 'ahooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import useWebSocket from 'react-use-websocket'
|
||||
import { ReadyState } from 'react-use-websocket'
|
||||
|
||||
import { renderFilterMessageType } from '@/components/onebot/filter_message_type'
|
||||
|
||||
import { isOB11Event, isOB11RequestResponse } from '@/utils/onebot'
|
||||
|
||||
import type { AllOB11WsResponse } from '@/types/onebot'
|
||||
|
||||
export { ReadyState } from 'react-use-websocket'
|
||||
export function useWebSocketDebug(url: string, token: string) {
|
||||
const messageHistory = useReactive<AllOB11WsResponse[]>([])
|
||||
const [filterTypes, setFilterTypes] = useState<Selection>('all')
|
||||
|
||||
const filteredMessages = messageHistory.filter((msg) => {
|
||||
if (filterTypes === 'all' || filterTypes.size === 0) return true
|
||||
if (isOB11Event(msg)) return filterTypes.has(msg.post_type)
|
||||
if (isOB11RequestResponse(msg)) return filterTypes.has('request')
|
||||
})
|
||||
|
||||
const { sendMessage, readyState } = useWebSocket(url, {
|
||||
onMessage: useCallback((event: WebSocketEventMap['message']) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
messageHistory.unshift(data)
|
||||
} catch (error) {
|
||||
toast.error('WebSocket 消息解析失败')
|
||||
}
|
||||
}, []),
|
||||
queryParams: {
|
||||
access_token: token
|
||||
},
|
||||
onError: (event) => {
|
||||
toast.error('WebSocket 连接失败')
|
||||
console.error('WebSocket error:', event)
|
||||
},
|
||||
onOpen: () => {
|
||||
messageHistory.splice(0, messageHistory.length)
|
||||
}
|
||||
})
|
||||
|
||||
const _sendMessage = (msg: string) => {
|
||||
if (readyState !== ReadyState.OPEN) {
|
||||
throw new Error('WebSocket 连接未建立')
|
||||
}
|
||||
sendMessage(msg)
|
||||
}
|
||||
|
||||
const FilterMessagesType = renderFilterMessageType(
|
||||
filterTypes,
|
||||
setFilterTypes
|
||||
)
|
||||
|
||||
return {
|
||||
sendMessage: _sendMessage,
|
||||
readyState,
|
||||
messageHistory,
|
||||
filteredMessages,
|
||||
filterTypes,
|
||||
setFilterTypes,
|
||||
FilterMessagesType
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import Quill from 'quill'
|
||||
import 'quill/dist/quill.core.css'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface UseCustomQuillProps {
|
||||
modules: Record<string, unknown>
|
||||
formats: string[]
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
export const useCustomQuill = ({
|
||||
modules,
|
||||
formats,
|
||||
placeholder
|
||||
}: UseCustomQuillProps) => {
|
||||
const quillRef = useRef<HTMLDivElement | null>(null)
|
||||
const [quill, setQuill] = useState<Quill | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (quillRef.current) {
|
||||
const quillInstance = new Quill(quillRef.current, {
|
||||
modules,
|
||||
formats,
|
||||
placeholder
|
||||
})
|
||||
setQuill(quillInstance)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { quillRef, quill, Quill }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
|
||||
import ShowStructedMessage from '@/components/chat_input/components/show_structed_message'
|
||||
|
||||
import { OB11Segment } from '@/types/onebot'
|
||||
|
||||
import useDialog from './use-dialog'
|
||||
|
||||
const useShowStructuredMessage = () => {
|
||||
const dialog = useDialog()
|
||||
|
||||
const showStructuredMessage = (messages: OB11Segment[]) => {
|
||||
dialog.alert({
|
||||
title: '消息内容',
|
||||
size: '3xl',
|
||||
content: createElement(ShowStructedMessage, {
|
||||
messages: messages
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return showStructuredMessage
|
||||
}
|
||||
|
||||
export default useShowStructuredMessage
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Link } from '@heroui/link'
|
||||
import { Tooltip } from '@heroui/tooltip'
|
||||
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
|
||||
export default function PureLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex flex-col h-screen">
|
||||
<div className="absolute right-0 top-0 p-2">
|
||||
<Tooltip content="查看WebUI源码" placement="left" showArrow>
|
||||
<Link isExternal href="https://github.com/bietiaop/NextNapCatWebUI">
|
||||
<GithubIcon className="text-default-900 hover:text-default-600 w-10 h-10 hover:drop-shadow-lg transition-all" />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<main className="flex-grow w-full flex flex-col justify-center items-center">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import 'react-photo-view/dist/react-photo-view.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
import App from '@/App.tsx'
|
||||
import { Provider } from '@/provider.tsx'
|
||||
import '@/styles/globals.css'
|
||||
|
||||
import key from './const/key'
|
||||
import WebUIManager from './controllers/webui_manager'
|
||||
import { loadTheme } from './utils/theme'
|
||||
|
||||
WebUIManager.checkWebUiLogined()
|
||||
|
||||
const token = localStorage.getItem(key.token)
|
||||
const theme = localStorage.getItem(key.theme)
|
||||
|
||||
// 兼容 useLocalStorage
|
||||
if (token && !token.startsWith('"')) {
|
||||
localStorage.setItem(key.token, JSON.stringify(token))
|
||||
}
|
||||
if (theme && !theme.startsWith('"')) {
|
||||
localStorage.setItem(key.theme, JSON.stringify(theme))
|
||||
}
|
||||
|
||||
loadTheme()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
// <React.StrictMode>
|
||||
<BrowserRouter basename="/webui/">
|
||||
<Provider>
|
||||
<App />
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
// </React.StrictMode>
|
||||
)
|
||||
@@ -1,200 +0,0 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Link } from '@heroui/link'
|
||||
import { Skeleton } from '@heroui/skeleton'
|
||||
import { Spinner } from '@heroui/spinner'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { useMemo } from 'react'
|
||||
import { BsTelegram, BsTencentQq } from 'react-icons/bs'
|
||||
import { IoDocument } from 'react-icons/io5'
|
||||
|
||||
import HoverTiltedCard from '@/components/hover_titled_card'
|
||||
import NapCatRepoInfo from '@/components/napcat_repo_info'
|
||||
import RotatingText from '@/components/rotating_text'
|
||||
|
||||
import { usePreloadImages } from '@/hooks/use-preload-images'
|
||||
import { useTheme } from '@/hooks/use-theme'
|
||||
|
||||
import logo from '@/assets/images/logo.png'
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
function VersionInfo() {
|
||||
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo)
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-2xl font-bold">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-primary-500 drop-shadow-md">NapCat</div>
|
||||
{error ? (
|
||||
error.message
|
||||
) : loading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<RotatingText
|
||||
texts={['WebUI', data?.version ?? '']}
|
||||
mainClassName="overflow-hidden flex items-center bg-primary-500 px-2 rounded-lg text-default-50 shadow-md"
|
||||
staggerFrom={'last'}
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '-120%' }}
|
||||
staggerDuration={0.025}
|
||||
splitLevelClassName="overflow-hidden"
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
|
||||
rotationInterval={2000}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const imageUrls = useMemo(
|
||||
() => [
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark'
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const { loadedUrls, isLoading } = usePreloadImages(imageUrls)
|
||||
|
||||
const getImageUrl = useMemo(
|
||||
() => (baseUrl: string) => {
|
||||
const theme = isDark ? 'dark' : 'light'
|
||||
const fullUrl = baseUrl.replace(
|
||||
/color_scheme=(?:light|dark)/,
|
||||
`color_scheme=${theme}`
|
||||
)
|
||||
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null
|
||||
},
|
||||
[isDark, isLoading, loadedUrls]
|
||||
)
|
||||
|
||||
const renderImage = useMemo(
|
||||
() => (baseUrl: string, alt: string) => {
|
||||
const imageUrl = getImageUrl(baseUrl)
|
||||
|
||||
if (!imageUrl) {
|
||||
return <Skeleton className="h-16 rounded-lg" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
className="flex-1 pointer-events-none select-none rounded-none"
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[getImageUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>关于 NapCat WebUI</title>
|
||||
<section className="max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10">
|
||||
<div className="w-full flex flex-col md:flex-row gap-4">
|
||||
<div className="flex flex-col md:flex-row items-center">
|
||||
<HoverTiltedCard imageSrc={logo} overlayContent="" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 py-2">
|
||||
<VersionInfo />
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-primary-400">NapCat 是什么?</p>
|
||||
<p className="text-default-800">
|
||||
基于TypeScript构建的Bot框架,通过相应的启动器或者框架,主动调用QQ
|
||||
Node模块提供给客户端的接口,实现Bot的功能.
|
||||
</p>
|
||||
<p className="font-bold text-primary-400">魔法版介绍</p>
|
||||
<p className="text-default-800">
|
||||
猫猫框架通过魔法的手段获得了 QQ 的发送消息、接收消息等接口。
|
||||
为了方便使用,猫猫框架将通过一种名为 OneBot 的约定将你的 HTTP /
|
||||
WebSocket 请求按照规范读取,
|
||||
再去调用猫猫框架所获得的QQ发送接口之类的接口。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 flex-wrap justify-around">
|
||||
<Card
|
||||
as={Link}
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isExternal
|
||||
href="https://qm.qq.com/q/F9cgs1N3Mc"
|
||||
>
|
||||
<CardBody className="flex-row items-center gap-2">
|
||||
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群1</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isExternal
|
||||
href="https://qm.qq.com/q/hSt0u9PVn"
|
||||
>
|
||||
<CardBody className="flex-row items-center gap-2">
|
||||
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群2</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isExternal
|
||||
href="https://t.me/napcatqq"
|
||||
>
|
||||
<CardBody className="flex-row items-center gap-2">
|
||||
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||
<BsTelegram size={16} />
|
||||
</span>
|
||||
<span>Telegram</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow="sm"
|
||||
isPressable
|
||||
isExternal
|
||||
href="https://napcat.napneko.icu/"
|
||||
>
|
||||
<CardBody className="flex-row items-center gap-2">
|
||||
<span className="p-2 rounded-small bg-primary-50 text-primary-500">
|
||||
<IoDocument size={16} />
|
||||
</span>
|
||||
<span>使用文档</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-4">
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{renderImage(
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||
'Contributors'
|
||||
)}
|
||||
{renderImage(
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||
'Activity Trends'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NapCatRepoInfo />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Input } from '@heroui/input'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { useEffect } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
|
||||
import QQManager from '@/controllers/qq_manager'
|
||||
|
||||
const LoginConfigCard = () => {
|
||||
const {
|
||||
data: quickLoginData,
|
||||
loading: quickLoginLoading,
|
||||
error: quickLoginError,
|
||||
refreshAsync: refreshQuickLogin
|
||||
} = useRequest(QQManager.getQuickLoginQQ)
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setOnebotValue
|
||||
} = useForm<{
|
||||
quickLoginQQ: string
|
||||
}>({
|
||||
defaultValues: {
|
||||
quickLoginQQ: ''
|
||||
}
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
setOnebotValue('quickLoginQQ', quickLoginData ?? '')
|
||||
}
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
try {
|
||||
await QQManager.setQuickLoginQQ(data.quickLoginQQ)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
await refreshQuickLogin()
|
||||
toast.success('刷新成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`刷新失败: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [quickLoginData])
|
||||
|
||||
if (quickLoginLoading) return <PageLoading loading={true} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>OneBot配置 - NapCat WebUI</title>
|
||||
<div className="flex-shrink-0 w-full">快速登录QQ</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="quickLoginQQ"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="快速登录QQ"
|
||||
placeholder="请输入QQ号"
|
||||
isDisabled={!!quickLoginError}
|
||||
errorMessage={quickLoginError ? '获取快速登录QQ失败' : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting || quickLoginLoading}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginConfigCard
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Input } from '@heroui/input'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import PageLoading from '@/components/page_loading'
|
||||
import SwitchCard from '@/components/switch_card'
|
||||
|
||||
import useConfig from '@/hooks/use-config'
|
||||
|
||||
const OneBotConfigCard = () => {
|
||||
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setOnebotValue
|
||||
} = useForm<IConfig['onebot']>({
|
||||
defaultValues: {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: false
|
||||
}
|
||||
})
|
||||
const reset = () => {
|
||||
setOnebotValue('musicSignUrl', config.musicSignUrl)
|
||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url)
|
||||
setOnebotValue('parseMultMsg', config.parseMultMsg)
|
||||
}
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
try {
|
||||
await saveConfigWithoutNetwork(data)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
const onRefresh = async (shotTip = true) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await refreshConfig()
|
||||
if (shotTip) toast.success('刷新成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`刷新失败: ${msg}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh(false)
|
||||
}, [])
|
||||
|
||||
if (loading) return <PageLoading loading={true} />
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>OneBot配置 - NapCat WebUI</title>
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicSignUrl"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="音乐签名地址"
|
||||
placeholder="请输入音乐签名地址"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enableLocalFile2Url"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用本地文件到URL"
|
||||
label="启用本地文件到URL"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parseMultMsg"
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description="启用上报解析合并消息"
|
||||
label="启用上报解析合并消息"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OneBotConfigCard
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Input } from '@heroui/input'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useEffect } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons'
|
||||
import FileInput from '@/components/input/file_input'
|
||||
import ImageInput from '@/components/input/image_input'
|
||||
|
||||
import useMusic from '@/hooks/use-music'
|
||||
|
||||
import { siteConfig } from '@/config/site'
|
||||
import FileManager from '@/controllers/file_manager'
|
||||
|
||||
const WebUIConfigCard = () => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setWebuiValue
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {}
|
||||
}
|
||||
})
|
||||
|
||||
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '')
|
||||
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
)
|
||||
const { setListId, listId } = useMusic()
|
||||
|
||||
const reset = () => {
|
||||
setWebuiValue('musicListID', listId)
|
||||
setWebuiValue('customIcons', customIcons)
|
||||
setWebuiValue('background', b64img)
|
||||
}
|
||||
|
||||
const onSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID)
|
||||
setCustomIcons(data.customIcons)
|
||||
setB64img(data.background)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
toast.error(`保存失败: ${msg}`)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [listId, customIcons, b64img])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">WebUI字体</div>
|
||||
<div className="text-sm text-default-400">
|
||||
此项不需要手动保存,上传成功后需清空网页缓存并刷新
|
||||
<FileInput
|
||||
label="中文字体"
|
||||
onChange={async (file) => {
|
||||
try {
|
||||
await FileManager.uploadWebUIFont(file)
|
||||
toast.success('上传成功')
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
toast.error('上传失败: ' + (error as Error).message)
|
||||
}
|
||||
}}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await FileManager.deleteWebUIFont()
|
||||
toast.success('删除成功')
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
toast.error('删除失败: ' + (error as Error).message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">WebUI音乐播放器</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="musicListID"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label="网易云音乐歌单ID(网页内音乐播放器)"
|
||||
placeholder="请输入歌单ID"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex-shrink-0 w-full">背景图</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="background"
|
||||
render={({ field }) => <ImageInput {...field} />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>自定义图标</div>
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<Controller
|
||||
key={item.label}
|
||||
control={control}
|
||||
name={`customIcons.${item.label}`}
|
||||
render={({ field }) => <ImageInput {...field} label={item.label} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebUIConfigCard
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
export default function DebugPage() {
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { Input } from '@heroui/input'
|
||||
import { useLocalStorage } from '@uidotdev/usehooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import key from '@/const/key'
|
||||
|
||||
import OneBotMessageList from '@/components/onebot/message_list'
|
||||
import OneBotSendModal from '@/components/onebot/send_modal'
|
||||
import WSStatus from '@/components/onebot/ws_status'
|
||||
|
||||
import { useWebSocketDebug } from '@/hooks/use-websocket-debug'
|
||||
|
||||
export default function WSDebug() {
|
||||
const url = new URL(window.location.origin)
|
||||
url.port = '3001'
|
||||
url.protocol = 'ws:'
|
||||
const defaultWsUrl = url.href
|
||||
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {
|
||||
url: defaultWsUrl,
|
||||
token: ''
|
||||
})
|
||||
const [inputUrl, setInputUrl] = useState(socketConfig.url)
|
||||
const [inputToken, setInputToken] = useState(socketConfig.token)
|
||||
|
||||
const { sendMessage, readyState, FilterMessagesType, filteredMessages } =
|
||||
useWebSocketDebug(socketConfig.url, socketConfig.token)
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (!inputUrl.startsWith('ws://') && !inputUrl.startsWith('wss://')) {
|
||||
toast.error('WebSocket URL 不合法')
|
||||
return
|
||||
}
|
||||
setSocketConfig({
|
||||
url: inputUrl,
|
||||
token: inputToken
|
||||
})
|
||||
}, [inputUrl, inputToken])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Websocket调试 - NapCat WebUI</title>
|
||||
<div className="h-[calc(100vh-4rem)] overflow-hidden flex flex-col">
|
||||
<Card className="mx-2 mt-2 flex-shrink-0 bg-opacity-50 backdrop-blur-sm">
|
||||
<CardBody className="gap-2">
|
||||
<div className="grid gap-2 items-center md:grid-cols-5">
|
||||
<Input
|
||||
className="col-span-2"
|
||||
label="WebSocket URL"
|
||||
type="text"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder="输入 WebSocket URL"
|
||||
/>
|
||||
<Input
|
||||
className="col-span-2"
|
||||
label="Token"
|
||||
type="text"
|
||||
value={inputToken}
|
||||
onChange={(e) => setInputToken(e.target.value)}
|
||||
placeholder="输入 Token"
|
||||
/>
|
||||
<div className="flex-shrink-0 flex gap-2 col-span-2 md:col-span-1">
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleConnect}
|
||||
size="lg"
|
||||
radius="full"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 border border-default-100 bg-content1 bg-opacity-50 rounded-md dark:bg-[rgb(30,30,30)]">
|
||||
<div className="grid gap-2 md:grid-cols-5 items-center md:w-fit">
|
||||
<WSStatus state={readyState} />
|
||||
<div className="md:w-64 max-w-full col-span-2">
|
||||
{FilterMessagesType}
|
||||
</div>
|
||||
<OneBotSendModal sendMessage={sendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<OneBotMessageList messages={filteredMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Card, CardBody } from '@heroui/card'
|
||||
import { useRequest } from 'ahooks'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import NetworkItemDisplay from '@/components/display_network_item'
|
||||
import Hitokoto from '@/components/hitokoto'
|
||||
import QQInfoCard from '@/components/qq_info_card'
|
||||
import SystemInfo from '@/components/system_info'
|
||||
import SystemStatusDisplay from '@/components/system_status_display'
|
||||
|
||||
import useConfig from '@/hooks/use-config'
|
||||
|
||||
import QQManager from '@/controllers/qq_manager'
|
||||
import WebUIManager from '@/controllers/webui_manager'
|
||||
|
||||
const Networks: React.FC = () => {
|
||||
const { config, refreshConfig } = useConfig()
|
||||
const allNetWorkConfigLength =
|
||||
config.network.httpClients.length +
|
||||
config.network.websocketClients.length +
|
||||
config.network.websocketServers.length +
|
||||
config.network.httpServers.length
|
||||
|
||||
useEffect(() => {
|
||||
refreshConfig()
|
||||
}, [])
|
||||
return (
|
||||
<div className="grid grid-cols-8 md:grid-cols-3 lg:grid-cols-6 gap-y-2 gap-x-1 md:gap-y-4 md:gap-x-4 py-5">
|
||||
<NetworkItemDisplay count={allNetWorkConfigLength} label="网络配置" />
|
||||
<NetworkItemDisplay
|
||||
count={config.network.httpServers.length}
|
||||
label="HTTP服务器"
|
||||
size="sm"
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.httpClients.length}
|
||||
label="HTTP客户端"
|
||||
size="sm"
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.websocketServers.length}
|
||||
label="WS服务器"
|
||||
size="sm"
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.websocketClients.length}
|
||||
label="WS客户端"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const QQInfo: React.FC = () => {
|
||||
const { data, loading, error } = useRequest(QQManager.getQQLoginInfo)
|
||||
return <QQInfoCard data={data} error={error} loading={loading} />
|
||||
}
|
||||
|
||||
export interface SystemStatusCardProps {
|
||||
setArchInfo: (arch: string | undefined) => void
|
||||
}
|
||||
const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>()
|
||||
const isSetted = useRef(false)
|
||||
const getStatus = useCallback(() => {
|
||||
try {
|
||||
const event = WebUIManager.getSystemStatus(setSystemStatus)
|
||||
return event
|
||||
} catch (error) {
|
||||
toast.error('获取系统状态失败')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const close = getStatus()
|
||||
return () => {
|
||||
close?.close()
|
||||
}
|
||||
}, [getStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (systemStatus?.arch && !isSetted.current) {
|
||||
setArchInfo(systemStatus.arch)
|
||||
isSetted.current = true
|
||||
}
|
||||
}, [systemStatus, setArchInfo])
|
||||
|
||||
return <SystemStatusDisplay data={systemStatus} />
|
||||
}
|
||||
|
||||
const DashboardIndexPage: React.FC = () => {
|
||||
const [archInfo, setArchInfo] = useState<string>()
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>基础信息 - NapCat WebUI</title>
|
||||
<section className="w-full p-2 md:p-4 md:max-w-[1000px] mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch">
|
||||
<div className="flex flex-col gap-2">
|
||||
<QQInfo />
|
||||
<SystemInfo archInfo={archInfo} />
|
||||
</div>
|
||||
<SystemStatusCard setArchInfo={setArchInfo} />
|
||||
</div>
|
||||
<Networks />
|
||||
<Card className="bg-opacity-60 shadow-sm shadow-primary-100">
|
||||
<CardBody>
|
||||
<Hitokoto />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardIndexPage
|
||||
@@ -1,176 +0,0 @@
|
||||
import { Button } from '@heroui/button'
|
||||
import { CardBody, CardHeader } from '@heroui/card'
|
||||
import { Image } from '@heroui/image'
|
||||
import { Tab, Tabs } from '@heroui/tabs'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card'
|
||||
import { title } from '@/components/primitives'
|
||||
import QrCodeLogin from '@/components/qr_code_login'
|
||||
import QuickLogin from '@/components/quick_login'
|
||||
import type { QQItem } from '@/components/quick_login'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
|
||||
import logo from '@/assets/images/logo.png'
|
||||
import QQManager from '@/controllers/qq_manager'
|
||||
import PureLayout from '@/layouts/pure'
|
||||
|
||||
export default function QQLoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [uinValue, setUinValue] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [qrcode, setQrcode] = useState<string>('')
|
||||
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([])
|
||||
const [refresh, setRefresh] = useState<boolean>(false)
|
||||
const firstLoad = useRef<boolean>(true)
|
||||
const onSubmit = async () => {
|
||||
if (!uinValue) {
|
||||
toast.error('请选择快捷登录的QQ')
|
||||
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await QQManager.setQuickLogin(uinValue)
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
|
||||
toast.error(`快速登录QQ失败: ${msg}`)
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateQrCode = async () => {
|
||||
if (firstLoad.current) setIsLoading(true)
|
||||
try {
|
||||
const data = await QQManager.checkQQLoginStatusWithQrcode()
|
||||
|
||||
if (firstLoad.current) {
|
||||
setIsLoading(false)
|
||||
firstLoad.current = false
|
||||
}
|
||||
if (data.isLogin) {
|
||||
toast.success('QQ登录成功')
|
||||
navigate('/', { replace: true })
|
||||
} else {
|
||||
setQrcode(data.qrcodeurl)
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
|
||||
toast.error(`获取二维码失败: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateQQList = async () => {
|
||||
setRefresh(true)
|
||||
try {
|
||||
const data = await QQManager.getQQQuickLoginListNew()
|
||||
setQQList(data)
|
||||
} catch (error) {
|
||||
try {
|
||||
const data = await QQManager.getQQQuickLoginList()
|
||||
|
||||
const qqList = data.map((item) => ({
|
||||
uin: item
|
||||
}))
|
||||
|
||||
setQQList(qqList)
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message
|
||||
|
||||
toast.error(`获取QQ列表失败: ${msg}`)
|
||||
}
|
||||
} finally {
|
||||
setRefresh(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement> = (
|
||||
e
|
||||
) => {
|
||||
setUinValue(e.target.value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
onUpdateQrCode()
|
||||
}, 3000)
|
||||
|
||||
onUpdateQrCode()
|
||||
onUpdateQQList()
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>QQ登录 - NapCat WebUI</title>
|
||||
<PureLayout>
|
||||
<div className="w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden">
|
||||
<HoverEffectCard
|
||||
className="items-center gap-4 pt-0 pb-6 bg-default-50"
|
||||
maxXRotation={3}
|
||||
maxYRotation={3}
|
||||
>
|
||||
<CardHeader className="inline-block max-w-lg text-center justify-center">
|
||||
<div className="flex items-center justify-center w-full gap-2 pt-10">
|
||||
<Image alt="logo" height="7em" src={logo} />
|
||||
<div>
|
||||
<span className={title()}>Web </span>
|
||||
<span className={title({ color: 'violet' })}>
|
||||
Login
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeSwitch className="absolute right-4 top-4" />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="flex gap-5 p-10 pt-0">
|
||||
<Tabs
|
||||
fullWidth
|
||||
classNames={{
|
||||
tabList: 'shadow-sm dark:shadow-none'
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
size="lg"
|
||||
>
|
||||
<Tab key="shortcut" title="快速登录">
|
||||
<QuickLogin
|
||||
handleSelectionChange={handleSelectionChange}
|
||||
isLoading={isLoading}
|
||||
qqList={qqList}
|
||||
refresh={refresh}
|
||||
selectedQQ={uinValue}
|
||||
onSubmit={onSubmit}
|
||||
onUpdateQQList={onUpdateQQList}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key="qrcode" title="扫码登录">
|
||||
<QrCodeLogin qrcode={qrcode} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Button
|
||||
className="w-fit mx-auto"
|
||||
variant="light"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
navigate('/web_login', {
|
||||
replace: true
|
||||
})
|
||||
}}
|
||||
>
|
||||
返回 Web Login
|
||||
</Button>
|
||||
</CardBody>
|
||||
</HoverEffectCard>
|
||||
</div>
|
||||
</PureLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { HeroUIProvider } from '@heroui/system'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export function Provider({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return <HeroUIProvider navigate={navigate}>{children}</HeroUIProvider>
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
import configSlice from './modules/config'
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
config: configSlice
|
||||
}
|
||||
})
|
||||
|
||||
export default store
|
||||
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
8502
package-lock.json
generated
8502
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
72
package.json
72
package.json
@@ -2,74 +2,20 @@
|
||||
"name": "napcat",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "4.8.116",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build:universal": "npm run build:webui && vite build --mode universal || exit 1",
|
||||
"build:framework": "npm run build:webui && vite build --mode framework || exit 1",
|
||||
"build:shell": "npm run build:webui && vite build --mode shell || exit 1",
|
||||
"build:webui": "cd napcat.webui && npm run build",
|
||||
"dev:universal": "vite build --mode universal",
|
||||
"dev:framework": "vite build --mode framework",
|
||||
"dev:shell": "vite build --mode shell",
|
||||
"dev:shell-analysis": "vite build --mode shell-analysis",
|
||||
"dev:webui": "cd napcat.webui && npm run dev",
|
||||
"lint": "eslint --fix src/**/*.{js,ts,vue}",
|
||||
"depend": "cd dist && npm install --omit=dev",
|
||||
"dev:depend": "npm i && cd napcat.webui && npm i"
|
||||
"build:shell": "pnpm --filter napcat-shell run build || exit 1",
|
||||
"build:framework": "pnpm --filter napcat-framework run build || exit 1",
|
||||
"build:webui": "pnpm --filter napcat-webui-frontend run build || exit 1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@eslint/compat": "^1.3.1",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
|
||||
"@log4js-node/log4js-api": "^1.0.2",
|
||||
"@napneko/nap-proto-core": "^0.0.4",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@rollup/plugin-typescript": "^12.1.4",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.1",
|
||||
"@types/on-finished": "^2.3.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/react-color": "^3.0.13",
|
||||
"@types/type-is": "^1.6.7",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"ajv": "^8.13.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"commander": "^13.0.0",
|
||||
"compressing": "^1.10.1",
|
||||
"cors": "^2.8.5",
|
||||
"esbuild": "0.25.8",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fast-xml-parser": "^4.3.6",
|
||||
"file-type": "^21.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"multer": "^2.0.1",
|
||||
"napcat.protobuf": "^1.1.4",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.35.1",
|
||||
"vite": "^7.1.1",
|
||||
"vite-plugin-cp": "^6.0.0",
|
||||
"vite-tsconfig-paths": "^5.1.0",
|
||||
"winston": "^3.17.0"
|
||||
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-cp": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.0.0",
|
||||
"silk-wasm": "^3.6.1",
|
||||
"express": "^5.0.0",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/napcat-common/package.json
Normal file
31
packages/napcat-common/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "napcat-common",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./src/*": {
|
||||
"import": "./src/*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"compressing": "^1.10.1",
|
||||
"json5": "^2.2.3",
|
||||
"ajv": "^8.13.0",
|
||||
"file-type": "^21.0.0",
|
||||
"napcat-image-size": "workspace:*",
|
||||
"napcat-core": "workspace:*",
|
||||
"silk-wasm": "^3.6.1",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
20
packages/napcat-common/src/audio-worker.ts
Normal file
20
packages/napcat-common/src/audio-worker.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { encode } from 'silk-wasm';
|
||||
import { parentPort } from 'worker_threads';
|
||||
|
||||
export interface EncodeArgs {
|
||||
input: ArrayBufferView | ArrayBuffer
|
||||
sampleRate: number
|
||||
}
|
||||
export function recvTask<T> (cb: (taskData: T) => Promise<unknown>) {
|
||||
parentPort?.on('message', async (taskData: T) => {
|
||||
try {
|
||||
const ret = await cb(taskData);
|
||||
parentPort?.postMessage(ret);
|
||||
} catch (error: unknown) {
|
||||
parentPort?.postMessage({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
recvTask<EncodeArgs>(async ({ input, sampleRate }) => {
|
||||
return await encode(input, sampleRate);
|
||||
});
|
||||
84
packages/napcat-common/src/audio.ts
Normal file
84
packages/napcat-common/src/audio.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import fsPromise from 'fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { EncodeResult, getDuration, getWavFileInfo, isSilk, isWav } from 'silk-wasm';
|
||||
import { LogWrapper } from '@/napcat-common/log';
|
||||
import { EncodeArgs } from '@/napcat-common/audio-worker';
|
||||
import { FFmpegService } from '@/napcat-common/ffmpeg';
|
||||
import { runTask } from './worker';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ALLOW_SAMPLE_RATE = [8000, 12000, 16000, 24000, 32000, 44100, 48000];
|
||||
|
||||
function getWorkerPath () {
|
||||
// return new URL(/* @vite-ignore */ './audio-worker.mjs', import.meta.url).href;
|
||||
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'audio-worker.mjs');
|
||||
}
|
||||
|
||||
async function guessDuration (pttPath: string, logger: LogWrapper) {
|
||||
const pttFileInfo = await fsPromise.stat(pttPath);
|
||||
const duration = Math.max(1, Math.floor(pttFileInfo.size / 1024 / 3)); // 3kb/s
|
||||
logger.log('通过文件大小估算语音的时长:', duration);
|
||||
return duration;
|
||||
}
|
||||
|
||||
async function handleWavFile (
|
||||
file: Buffer,
|
||||
filePath: string,
|
||||
pcmPath: string
|
||||
): Promise<{ input: Buffer; sampleRate: number }> {
|
||||
const { fmt } = getWavFileInfo(file);
|
||||
if (!ALLOW_SAMPLE_RATE.includes(fmt.sampleRate)) {
|
||||
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||
return { input: await fsPromise.readFile(pcmPath), sampleRate: result.sampleRate };
|
||||
}
|
||||
return { input: file, sampleRate: fmt.sampleRate };
|
||||
}
|
||||
|
||||
export async function encodeSilk (filePath: string, TEMP_DIR: string, logger: LogWrapper) {
|
||||
try {
|
||||
const file = await fsPromise.readFile(filePath);
|
||||
const pttPath = path.join(TEMP_DIR, randomUUID());
|
||||
if (!isSilk(file)) {
|
||||
logger.log(`语音文件${filePath}需要转换成silk`);
|
||||
const pcmPath = `${pttPath}.pcm`;
|
||||
// const { input, sampleRate } = isWav(file) ? await handleWavFile(file, filePath, pcmPath): { input: await FFmpegService.convert(filePath, pcmPath) ? await fsPromise.readFile(pcmPath) : Buffer.alloc(0), sampleRate: 24000 };
|
||||
let input: Buffer;
|
||||
let sampleRate: number;
|
||||
if (isWav(file)) {
|
||||
const result = await handleWavFile(file, filePath, pcmPath);
|
||||
input = result.input;
|
||||
sampleRate = result.sampleRate;
|
||||
} else {
|
||||
const result = await FFmpegService.convert(filePath, pcmPath);
|
||||
input = await fsPromise.readFile(pcmPath);
|
||||
sampleRate = result.sampleRate;
|
||||
}
|
||||
const silk = await runTask<EncodeArgs, EncodeResult>(getWorkerPath(), { input, sampleRate });
|
||||
fsPromise.unlink(pcmPath).catch((e) => logger.logError('删除临时文件失败', pcmPath, e));
|
||||
await fsPromise.writeFile(pttPath, Buffer.from(silk.data));
|
||||
logger.log(`语音文件${filePath}转换成功!`, pttPath, '时长:', silk.duration);
|
||||
return {
|
||||
converted: true,
|
||||
path: pttPath,
|
||||
duration: silk.duration / 1000,
|
||||
};
|
||||
} else {
|
||||
let duration = 0;
|
||||
try {
|
||||
duration = getDuration(file) / 1000;
|
||||
} catch (e: unknown) {
|
||||
logger.log('获取语音文件时长失败, 使用文件大小推测时长', filePath, (e as Error).stack);
|
||||
duration = await guessDuration(filePath, logger);
|
||||
}
|
||||
return {
|
||||
converted: false,
|
||||
path: filePath,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.logError('convert silk failed', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
79
packages/napcat-common/src/cancel-task.ts
Normal file
79
packages/napcat-common/src/cancel-task.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export type TaskExecutor<T> = (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void, onCancel: (callback: () => void) => void) => void | Promise<void>;
|
||||
|
||||
export class CancelableTask<T> {
|
||||
private promise: Promise<T>;
|
||||
private cancelCallback: (() => void) | null = null;
|
||||
private isCanceled = false;
|
||||
private cancelListeners: Array<() => void> = [];
|
||||
|
||||
constructor (executor: TaskExecutor<T>) {
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
const onCancel = (callback: () => void) => {
|
||||
this.cancelCallback = callback;
|
||||
};
|
||||
|
||||
const execute = async () => {
|
||||
try {
|
||||
await executor(
|
||||
(value) => {
|
||||
if (!this.isCanceled) {
|
||||
resolve(value);
|
||||
}
|
||||
},
|
||||
(reason) => {
|
||||
if (!this.isCanceled) {
|
||||
reject(reason);
|
||||
}
|
||||
},
|
||||
onCancel
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isCanceled) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
execute();
|
||||
});
|
||||
}
|
||||
|
||||
public cancel () {
|
||||
if (this.cancelCallback) {
|
||||
this.cancelCallback();
|
||||
}
|
||||
this.isCanceled = true;
|
||||
this.cancelListeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
public isTaskCanceled (): boolean {
|
||||
return this.isCanceled;
|
||||
}
|
||||
|
||||
public onCancel (listener: () => void) {
|
||||
this.cancelListeners.push(listener);
|
||||
}
|
||||
|
||||
public then<TResult1 = T, TResult2 = never>(
|
||||
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,
|
||||
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.promise.then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
public catch<TResult = never>(
|
||||
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null
|
||||
): Promise<T | TResult> {
|
||||
return this.promise.catch(onrejected);
|
||||
}
|
||||
|
||||
public finally (onfinally?: (() => void) | undefined | null): Promise<T> {
|
||||
return this.promise.finally(onfinally);
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator] () {
|
||||
return {
|
||||
next: () => this.promise.then(value => ({ value, done: true })),
|
||||
};
|
||||
}
|
||||
}
|
||||
229
packages/napcat-common/src/clean-task.ts
Normal file
229
packages/napcat-common/src/clean-task.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import fs from 'fs';
|
||||
// generate Claude 3.7 Sonet Thinking
|
||||
|
||||
interface FileRecord {
|
||||
filePath: string;
|
||||
addedTime: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
fileRecord: FileRecord;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
class CleanupQueue {
|
||||
private tasks: Map<string, CleanupTask> = new Map();
|
||||
private readonly MAX_RETRIES = 3;
|
||||
private isProcessing: boolean = false;
|
||||
private pendingOperations: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* 执行队列中的待处理操作,确保异步安全
|
||||
*/
|
||||
private executeNextOperation (): void {
|
||||
if (this.pendingOperations.length === 0) {
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
const operation = this.pendingOperations.shift();
|
||||
operation?.();
|
||||
|
||||
// 使用 setImmediate 允许事件循环继续,防止阻塞
|
||||
setImmediate(() => this.executeNextOperation());
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全执行操作,防止竞态条件
|
||||
* @param operation 要执行的操作
|
||||
*/
|
||||
private safeExecute (operation: () => void): void {
|
||||
this.pendingOperations.push(operation);
|
||||
if (!this.isProcessing) {
|
||||
this.executeNextOperation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param filePath 文件路径
|
||||
* @returns 文件是否存在
|
||||
*/
|
||||
private fileExists (filePath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch (_error) {
|
||||
// console.log(`检查文件存在出错: ${filePath}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加文件到清理队列
|
||||
* @param filePath 文件路径
|
||||
* @param cleanupDelay 清理延迟时间(毫秒)
|
||||
*/
|
||||
addFile (filePath: string, cleanupDelay: number): void {
|
||||
this.safeExecute(() => {
|
||||
// 如果文件已在队列中,取消原来的计时器
|
||||
if (this.tasks.has(filePath)) {
|
||||
this.cancelCleanup(filePath);
|
||||
}
|
||||
|
||||
// 创建新的文件记录
|
||||
const fileRecord: FileRecord = {
|
||||
filePath,
|
||||
addedTime: Date.now(),
|
||||
retries: 0,
|
||||
};
|
||||
|
||||
// 设置计时器
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(fileRecord, cleanupDelay);
|
||||
}, cleanupDelay);
|
||||
|
||||
// 添加到任务队列
|
||||
this.tasks.set(filePath, { fileRecord, timer });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加文件到清理队列
|
||||
* @param filePaths 文件路径数组
|
||||
* @param cleanupDelay 清理延迟时间(毫秒)
|
||||
*/
|
||||
addFiles (filePaths: string[], cleanupDelay: number): void {
|
||||
this.safeExecute(() => {
|
||||
for (const filePath of filePaths) {
|
||||
// 内部直接处理,不通过 safeExecute 以保证批量操作的原子性
|
||||
if (this.tasks.has(filePath)) {
|
||||
// 取消已有的计时器,但不使用 cancelCleanup 方法以避免重复的安全检查
|
||||
const existingTask = this.tasks.get(filePath);
|
||||
if (existingTask) {
|
||||
clearTimeout(existingTask.timer);
|
||||
}
|
||||
}
|
||||
|
||||
const fileRecord: FileRecord = {
|
||||
filePath,
|
||||
addedTime: Date.now(),
|
||||
retries: 0,
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(fileRecord, cleanupDelay);
|
||||
}, cleanupDelay);
|
||||
|
||||
this.tasks.set(filePath, { fileRecord, timer });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件
|
||||
* @param record 文件记录
|
||||
* @param delay 延迟时间,用于重试
|
||||
*/
|
||||
private cleanupFile (record: FileRecord, delay: number): void {
|
||||
this.safeExecute(() => {
|
||||
// 首先检查文件是否存在,不存在则视为清理成功
|
||||
if (!this.fileExists(record.filePath)) {
|
||||
// console.log(`文件已不存在,跳过清理: ${record.filePath}`);
|
||||
this.tasks.delete(record.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试删除文件
|
||||
fs.unlinkSync(record.filePath);
|
||||
// 删除成功,从队列中移除任务
|
||||
this.tasks.delete(record.filePath);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
|
||||
// 明确处理文件不存在的情况
|
||||
if (err.code === 'ENOENT') {
|
||||
// console.log(`文件在删除时不存在,视为清理成功: ${record.filePath}`);
|
||||
this.tasks.delete(record.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件没有访问权限等情况
|
||||
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
||||
// console.error(`没有权限删除文件: ${record.filePath}`, err);
|
||||
}
|
||||
|
||||
// 其他删除失败情况,考虑重试
|
||||
if (record.retries < this.MAX_RETRIES - 1) {
|
||||
// 还有重试机会,增加重试次数
|
||||
record.retries++;
|
||||
// console.log(`清理文件失败,将重试(${record.retries}/${this.MAX_RETRIES}): ${record.filePath}`);
|
||||
|
||||
// 设置相同的延迟时间再次尝试
|
||||
const timer = setTimeout(() => {
|
||||
this.cleanupFile(record, delay);
|
||||
}, delay);
|
||||
|
||||
// 更新任务
|
||||
this.tasks.set(record.filePath, { fileRecord: record, timer });
|
||||
} else {
|
||||
// 已达到最大重试次数,从队列中移除任务
|
||||
this.tasks.delete(record.filePath);
|
||||
// console.error(`清理文件失败,已达最大重试次数(${this.MAX_RETRIES}): ${record.filePath}`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消文件的清理任务
|
||||
* @param filePath 文件路径
|
||||
* @returns 是否成功取消
|
||||
*/
|
||||
cancelCleanup (filePath: string): boolean {
|
||||
let cancelled = false;
|
||||
this.safeExecute(() => {
|
||||
const task = this.tasks.get(filePath);
|
||||
if (task) {
|
||||
clearTimeout(task.timer);
|
||||
this.tasks.delete(filePath);
|
||||
cancelled = true;
|
||||
}
|
||||
});
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列中的文件数量
|
||||
* @returns 文件数量
|
||||
*/
|
||||
getQueueSize (): number {
|
||||
return this.tasks.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有待清理的文件
|
||||
* @returns 文件路径数组
|
||||
*/
|
||||
getPendingFiles (): string[] {
|
||||
return Array.from(this.tasks.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有清理任务
|
||||
*/
|
||||
clearAll (): void {
|
||||
this.safeExecute(() => {
|
||||
// 取消所有定时器
|
||||
for (const task of this.tasks.values()) {
|
||||
clearTimeout(task.timer);
|
||||
}
|
||||
this.tasks.clear();
|
||||
// console.log('已清空所有清理任务');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanTaskQueue = new CleanupQueue();
|
||||
74
packages/napcat-common/src/config-base.ts
Normal file
74
packages/napcat-common/src/config-base.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import type { NapCatCore } from 'napcat-core';
|
||||
import json5 from 'json5';
|
||||
import Ajv, { AnySchema, ValidateFunction } from 'ajv';
|
||||
|
||||
export abstract class ConfigBase<T> {
|
||||
name: string;
|
||||
core: NapCatCore;
|
||||
configPath: string;
|
||||
configData: T = {} as T;
|
||||
ajv: Ajv;
|
||||
validate: ValidateFunction<T>;
|
||||
|
||||
protected constructor (name: string, core: NapCatCore, configPath: string, ConfigSchema: AnySchema) {
|
||||
this.name = name;
|
||||
this.core = core;
|
||||
this.configPath = configPath;
|
||||
this.ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
this.validate = this.ajv.compile<T>(ConfigSchema);
|
||||
fs.mkdirSync(this.configPath, { recursive: true });
|
||||
this.read();
|
||||
}
|
||||
|
||||
getConfigPath (pathName?: string): string {
|
||||
const filename = pathName ? `${this.name}_${pathName}.json` : `${this.name}.json`;
|
||||
return path.join(this.configPath, filename);
|
||||
}
|
||||
|
||||
read (): T {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
const defaultConfigPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
if (fs.existsSync(defaultConfigPath)) {
|
||||
this.configData = this.loadConfig(defaultConfigPath);
|
||||
}
|
||||
this.save();
|
||||
return this.configData;
|
||||
}
|
||||
return this.loadConfig(configPath);
|
||||
}
|
||||
|
||||
private loadConfig (configPath: string): T {
|
||||
try {
|
||||
const newConfigData = json5.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
this.validate(newConfigData);
|
||||
this.configData = newConfigData;
|
||||
this.core.context.logger.logDebug(`[Core] [Config] 配置文件${configPath}加载`, this.configData);
|
||||
return this.configData;
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, '读取配置文件时发生错误');
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
save (newConfigData: T = this.configData): void {
|
||||
const configPath = this.getConfigPath(this.core.selfInfo.uin);
|
||||
this.validate(newConfigData);
|
||||
this.configData = newConfigData;
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(this.configData, null, 2));
|
||||
} catch (e: unknown) {
|
||||
this.handleError(e, `保存配置文件 ${configPath} 时发生错误:`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError (e: unknown, message: string): void {
|
||||
if (e instanceof SyntaxError) {
|
||||
this.core.context.logger.logError('[Core] [Config] 操作配置文件格式错误,请检查配置文件:', e.message);
|
||||
} else {
|
||||
this.core.context.logger.logError(`[Core] [Config] ${message}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
359
packages/napcat-common/src/download-ffmpeg.ts
Normal file
359
packages/napcat-common/src/download-ffmpeg.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
// 更正导入语句
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import * as os from 'os';
|
||||
import * as compressing from 'compressing'; // 修正导入方式
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { LogWrapper } from './log';
|
||||
|
||||
const downloadOri = 'https://github.com/NapNeko/ffmpeg-build/releases/download/v1.0.0/ffmpeg-7.1.1-win64.zip';
|
||||
const urls = [
|
||||
'https://j.1win.ggff.net/' + downloadOri,
|
||||
'https://git.yylx.win/' + downloadOri,
|
||||
'https://ghfile.geekertao.top/' + downloadOri,
|
||||
'https://gh-proxy.net/' + downloadOri,
|
||||
'https://ghm.078465.xyz/' + downloadOri,
|
||||
'https://gitproxy.127731.xyz/' + downloadOri,
|
||||
'https://jiashu.1win.eu.org/' + downloadOri,
|
||||
'https://github.tbedu.top/' + downloadOri,
|
||||
downloadOri,
|
||||
];
|
||||
|
||||
/**
|
||||
* 测试URL是否可用
|
||||
* @param url 待测试的URL
|
||||
* @returns 如果URL可访问返回true,否则返回false
|
||||
*/
|
||||
async function testUrl (url: string): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const req = https.get(url, { timeout: 5000 }, (res) => {
|
||||
// 检查状态码是否表示成功
|
||||
const statusCode = res.statusCode || 0;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
// 终止请求并返回true
|
||||
req.destroy();
|
||||
resolve(true);
|
||||
} else {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找第一个可用的URL
|
||||
* @returns 返回第一个可用的URL,如果都不可用则返回null
|
||||
*/
|
||||
async function findAvailableUrl (): Promise<string | null> {
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const available = await testUrl(url);
|
||||
if (available) {
|
||||
return url;
|
||||
}
|
||||
} catch (_error) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 下载文件
|
||||
* @param url 下载URL
|
||||
* @param destPath 目标保存路径
|
||||
* @returns 成功返回true,失败返回false
|
||||
*/
|
||||
async function downloadFile (url: string, destPath: string, progressCallback?: (percent: number) => void): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const req = https.get(url, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
// 获取文件总大小
|
||||
const totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let downloadedSize = 0;
|
||||
let lastReportedPercent = -1; // 上次报告的百分比
|
||||
let lastReportTime = 0; // 上次报告的时间戳
|
||||
|
||||
// 如果有内容长度和进度回调,则添加数据监听
|
||||
if (totalSize > 0 && progressCallback) {
|
||||
// 初始报告 0%
|
||||
progressCallback(0);
|
||||
lastReportTime = Date.now();
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
const currentPercent = Math.floor((downloadedSize / totalSize) * 100);
|
||||
const now = Date.now();
|
||||
|
||||
// 只在以下条件触发回调:
|
||||
// 1. 百分比变化至少为1%
|
||||
// 2. 距离上次报告至少500毫秒
|
||||
// 3. 确保报告100%完成
|
||||
if ((currentPercent !== lastReportedPercent &&
|
||||
(currentPercent - lastReportedPercent >= 1 || currentPercent === 100)) &&
|
||||
(now - lastReportTime >= 1000 || currentPercent === 100)) {
|
||||
progressCallback(currentPercent);
|
||||
lastReportedPercent = currentPercent;
|
||||
lastReportTime = now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pipeline(res, file)
|
||||
.then(() => {
|
||||
// 确保最后报告100%
|
||||
if (progressCallback && lastReportedPercent !== 100) {
|
||||
progressCallback(100);
|
||||
}
|
||||
resolve(true);
|
||||
})
|
||||
.catch(() => resolve(false));
|
||||
} else {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => { });
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => { });
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压缩zip文件中的特定内容
|
||||
* 只解压bin目录中的文件到目标目录
|
||||
* @param zipPath 压缩文件路径
|
||||
* @param extractDir 解压目标路径
|
||||
*/
|
||||
async function extractBinDirectory (zipPath: string, extractDir: string): Promise<void> {
|
||||
// 确保目标目录存在
|
||||
if (!fs.existsSync(extractDir)) {
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 解压文件
|
||||
const zipStream = new compressing.zip.UncompressStream({ source: zipPath });
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// 监听条目事件
|
||||
zipStream.on('entry', (header, stream, next) => {
|
||||
// 获取文件路径
|
||||
const filePath = header.name;
|
||||
|
||||
// 匹配内层bin目录中的文件
|
||||
// 例如:ffmpeg-n7.1.1-6-g48c0f071d4-win64-lgpl-7.1/bin/ffmpeg.exe
|
||||
if (filePath.includes('/bin/') && filePath.endsWith('.exe')) {
|
||||
// 提取文件名
|
||||
const fileName = path.basename(filePath);
|
||||
const targetPath = path.join(extractDir, fileName);
|
||||
|
||||
// 创建写入流
|
||||
const writeStream = fs.createWriteStream(targetPath);
|
||||
|
||||
// 将流管道连接到文件
|
||||
stream.pipe(writeStream);
|
||||
|
||||
// 监听写入完成事件
|
||||
writeStream.on('finish', () => {
|
||||
next();
|
||||
});
|
||||
|
||||
writeStream.on('error', () => {
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
// 跳过不需要的文件
|
||||
stream.resume();
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
zipStream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
zipStream.on('finish', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载并设置FFmpeg
|
||||
* @param destDir 目标安装目录,默认为用户临时目录下的ffmpeg文件夹
|
||||
* @param tempDir 临时文件目录,默认为系统临时目录
|
||||
* @returns 返回ffmpeg可执行文件的路径,如果失败则返回null
|
||||
*/
|
||||
export async function downloadFFmpeg (
|
||||
destDir?: string,
|
||||
tempDir?: string,
|
||||
progressCallback?: (percent: number, stage: string) => void
|
||||
): Promise<string | null> {
|
||||
// 仅限Windows
|
||||
if (os.platform() !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const destinationDir = destDir || path.join(os.tmpdir(), 'ffmpeg');
|
||||
const tempDirectory = tempDir || os.tmpdir();
|
||||
const zipFilePath = path.join(tempDirectory, 'ffmpeg.zip'); // 临时下载到指定临时目录
|
||||
const ffmpegExePath = path.join(destinationDir, 'ffmpeg.exe');
|
||||
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(destinationDir)) {
|
||||
fs.mkdirSync(destinationDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(tempDirectory)) {
|
||||
fs.mkdirSync(tempDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
// 如果ffmpeg已经存在,直接返回路径
|
||||
if (fs.existsSync(ffmpegExePath)) {
|
||||
if (progressCallback) progressCallback(100, '已找到FFmpeg');
|
||||
return ffmpegExePath;
|
||||
}
|
||||
|
||||
// 查找可用URL
|
||||
if (progressCallback) progressCallback(0, '查找可用下载源');
|
||||
const availableUrl = await findAvailableUrl();
|
||||
if (!availableUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
if (progressCallback) progressCallback(5, '开始下载FFmpeg');
|
||||
const downloaded = await downloadFile(
|
||||
availableUrl,
|
||||
zipFilePath,
|
||||
(percent) => {
|
||||
// 下载占总进度的70%
|
||||
if (progressCallback) progressCallback(5 + Math.floor(percent * 0.7), '下载FFmpeg');
|
||||
}
|
||||
);
|
||||
|
||||
if (!downloaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 直接解压bin目录文件到目标目录
|
||||
if (progressCallback) progressCallback(75, '解压FFmpeg');
|
||||
await extractBinDirectory(zipFilePath, destinationDir);
|
||||
|
||||
// 清理下载文件
|
||||
if (progressCallback) progressCallback(95, '清理临时文件');
|
||||
try {
|
||||
fs.unlinkSync(zipFilePath);
|
||||
} catch (_err) {
|
||||
// 忽略清理临时文件失败的错误
|
||||
}
|
||||
|
||||
// 检查ffmpeg.exe是否成功解压
|
||||
if (fs.existsSync(ffmpegExePath)) {
|
||||
if (progressCallback) progressCallback(100, 'FFmpeg安装完成');
|
||||
return ffmpegExePath;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查系统PATH环境变量中是否存在指定可执行文件
|
||||
* @param executable 可执行文件名
|
||||
* @returns 如果找到返回完整路径,否则返回null
|
||||
*/
|
||||
function findExecutableInPath (executable: string): string | null {
|
||||
// 仅适用于Windows系统
|
||||
if (os.platform() !== 'win32') return null;
|
||||
|
||||
// 获取PATH环境变量
|
||||
const pathEnv = process.env['PATH'] || '';
|
||||
const pathDirs = pathEnv.split(';');
|
||||
|
||||
// 检查每个目录
|
||||
for (const dir of pathDirs) {
|
||||
if (!dir) continue;
|
||||
try {
|
||||
const filePath = path.join(dir, executable);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
} catch (_error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function downloadFFmpegIfNotExists (log: LogWrapper) {
|
||||
// 仅限Windows
|
||||
if (os.platform() !== 'win32') {
|
||||
return {
|
||||
path: null,
|
||||
reset: false,
|
||||
};
|
||||
}
|
||||
const ffmpegInPath = findExecutableInPath('ffmpeg.exe');
|
||||
const ffprobeInPath = findExecutableInPath('ffprobe.exe');
|
||||
|
||||
if (ffmpegInPath && ffprobeInPath) {
|
||||
const ffmpegDir = path.dirname(ffmpegInPath);
|
||||
return {
|
||||
path: ffmpegDir,
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 如果环境变量中没有,检查项目目录中是否存在
|
||||
const currentPath = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ffmpeg_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffmpeg.exe'));
|
||||
const ffprobe_exist = fs.existsSync(path.join(currentPath, 'ffmpeg', 'ffprobe.exe'));
|
||||
|
||||
if (!ffmpeg_exist || !ffprobe_exist) {
|
||||
const url = await downloadFFmpeg(path.join(currentPath, 'ffmpeg'), path.join(currentPath, 'cache'), (percentage: number, message: string) => {
|
||||
log.log(`[FFmpeg] [Download] ${percentage}% - ${message}`);
|
||||
});
|
||||
if (!url) {
|
||||
log.log('[FFmpeg] [Error] 下载FFmpeg失败');
|
||||
return {
|
||||
path: null,
|
||||
reset: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: path.join(currentPath, 'ffmpeg'),
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: path.join(currentPath, 'ffmpeg'),
|
||||
reset: true,
|
||||
};
|
||||
}
|
||||
9
packages/napcat-common/src/env.d.ts
vendored
Normal file
9
packages/napcat-common/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare global {
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_NAPCAT_VERSION: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
272
packages/napcat-common/src/event.ts
Normal file
272
packages/napcat-common/src/event.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ListenerNamingMapping, ServiceNamingMapping } from '@/napcat-core';
|
||||
|
||||
interface InternalMapKey {
|
||||
timeout: number;
|
||||
createtime: number;
|
||||
func: (...arg: any[]) => any;
|
||||
checker: ((...args: any[]) => boolean) | undefined;
|
||||
}
|
||||
|
||||
type EnsureFunc<T> = T extends (...args: any) => any ? T : never;
|
||||
|
||||
type FuncKeys<T> = Extract<
|
||||
{
|
||||
[K in keyof T]: EnsureFunc<T[K]> extends never ? never : K;
|
||||
}[keyof T],
|
||||
string
|
||||
>;
|
||||
|
||||
export type ListenerClassBase = Record<string, string>;
|
||||
|
||||
export class NTEventWrapper {
|
||||
private readonly WrapperSession: NodeIQQNTWrapperSession | undefined; // WrapperSession
|
||||
private readonly listenerManager: Map<string, ListenerClassBase> = new Map<string, ListenerClassBase>(); // ListenerName-Unique -> Listener实例
|
||||
private readonly EventTask = new Map<string, Map<string, Map<string, InternalMapKey>>>(); // tasks ListenerMainName -> ListenerSubName-> uuid -> {timeout,createtime,func}
|
||||
|
||||
constructor (
|
||||
wrapperSession: NodeIQQNTWrapperSession
|
||||
) {
|
||||
this.WrapperSession = wrapperSession;
|
||||
}
|
||||
|
||||
createProxyDispatch (ListenerMainName: string) {
|
||||
const dispatcherListenerFunc = this.dispatcherListener.bind(this);
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get (target: any, prop: any, receiver: any) {
|
||||
if (typeof target[prop] === 'undefined') {
|
||||
// 如果方法不存在,返回一个函数,这个函数调用existentMethod
|
||||
return (...args: any[]) => {
|
||||
dispatcherListenerFunc(ListenerMainName, prop, ...args).then();
|
||||
};
|
||||
}
|
||||
// 如果方法存在,正常返回
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
createEventFunction<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
T extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
|
||||
> (eventName: `${Service}/${ServiceMethod}`): T | undefined {
|
||||
const eventNameArr = eventName.split('/');
|
||||
type eventType = {
|
||||
[key: string]: () => { [key: string]: (...params: Parameters<T>) => Promise<ReturnType<T>>; };
|
||||
};
|
||||
if (eventNameArr.length > 1) {
|
||||
const serviceName = 'get' + (eventNameArr[0]?.replace('NodeIKernel', '') ?? '');
|
||||
const eventName = eventNameArr[1];
|
||||
const services = (this.WrapperSession as unknown as eventType)[serviceName]?.();
|
||||
if (!services || !eventName) {
|
||||
return undefined;
|
||||
}
|
||||
let event = services[eventName];
|
||||
|
||||
// 重新绑定this
|
||||
event = event?.bind(services);
|
||||
if (event) {
|
||||
return event as T;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
createListenerFunction<T> (listenerMainName: string, uniqueCode: string = ''): T {
|
||||
const existListener = this.listenerManager.get(listenerMainName + uniqueCode);
|
||||
if (!existListener) {
|
||||
const Listener = this.createProxyDispatch(listenerMainName);
|
||||
const ServiceSubName = /^NodeIKernel(.*?)Listener$/.exec(listenerMainName)![1];
|
||||
const Service = `NodeIKernel${ServiceSubName}Service/addKernel${ServiceSubName}Listener`;
|
||||
|
||||
// @ts-ignore
|
||||
this.createEventFunction(Service)(Listener as T);
|
||||
this.listenerManager.set(listenerMainName + uniqueCode, Listener);
|
||||
return Listener as T;
|
||||
}
|
||||
return existListener as T;
|
||||
}
|
||||
|
||||
// 统一回调清理事件
|
||||
async dispatcherListener (ListenerMainName: string, ListenerSubName: string, ...args: any[]) {
|
||||
this.EventTask.get(ListenerMainName)
|
||||
?.get(ListenerSubName)
|
||||
?.forEach((task, uuid) => {
|
||||
if (task.createtime + task.timeout < Date.now()) {
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.delete(uuid);
|
||||
return;
|
||||
}
|
||||
if (task?.checker?.(...args)) {
|
||||
task.func(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callNoListenerEvent<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>
|
||||
> (
|
||||
serviceAndMethod: `${Service}/${ServiceMethod}`,
|
||||
...args: Parameters<EventType>
|
||||
): Promise<Awaited<ReturnType<EventType>>> {
|
||||
return (this.createEventFunction(serviceAndMethod))!(...args);
|
||||
}
|
||||
|
||||
async registerListen<
|
||||
Listener extends keyof ListenerNamingMapping,
|
||||
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
|
||||
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
|
||||
> (
|
||||
listenerAndMethod: `${Listener}/${ListenerMethod}`,
|
||||
checker: (...args: Parameters<ListenerType>) => boolean,
|
||||
waitTimes = 1,
|
||||
timeout = 5000
|
||||
) {
|
||||
return new Promise<Parameters<ListenerType>>((resolve, reject) => {
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||
const id = randomUUID();
|
||||
let complete = 0;
|
||||
let retData: Parameters<ListenerType> | undefined;
|
||||
|
||||
function sendDataCallback () {
|
||||
if (complete === 0) {
|
||||
reject(new Error(' ListenerName:' + listenerAndMethod + ' timeout'));
|
||||
} else {
|
||||
resolve(retData!);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutRef = setTimeout(sendDataCallback, timeout);
|
||||
const eventCallback = {
|
||||
timeout,
|
||||
createtime: Date.now(),
|
||||
checker,
|
||||
func: (...args: Parameters<ListenerType>) => {
|
||||
complete++;
|
||||
retData = args;
|
||||
if (complete >= waitTimes) {
|
||||
clearTimeout(timeoutRef);
|
||||
sendDataCallback();
|
||||
}
|
||||
},
|
||||
};
|
||||
if (!this.EventTask.get(ListenerMainName)) {
|
||||
this.EventTask.set(ListenerMainName, new Map());
|
||||
}
|
||||
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
|
||||
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
|
||||
}
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
|
||||
this.createListenerFunction(ListenerMainName);
|
||||
});
|
||||
}
|
||||
|
||||
async callNormalEventV2<
|
||||
Service extends keyof ServiceNamingMapping,
|
||||
ServiceMethod extends FuncKeys<ServiceNamingMapping[Service]>,
|
||||
Listener extends keyof ListenerNamingMapping,
|
||||
ListenerMethod extends FuncKeys<ListenerNamingMapping[Listener]>,
|
||||
EventType extends (...args: any) => any = EnsureFunc<ServiceNamingMapping[Service][ServiceMethod]>,
|
||||
ListenerType extends (...args: any) => any = EnsureFunc<ListenerNamingMapping[Listener][ListenerMethod]>
|
||||
> (
|
||||
serviceAndMethod: `${Service}/${ServiceMethod}`,
|
||||
listenerAndMethod: `${Listener}/${ListenerMethod}`,
|
||||
args: Parameters<EventType>,
|
||||
checkerEvent: (ret: Awaited<ReturnType<EventType>>) => boolean = () => true,
|
||||
checkerListener: (...args: Parameters<ListenerType>) => boolean = () => true,
|
||||
callbackTimesToWait = 1,
|
||||
timeout = 5000
|
||||
) {
|
||||
const id = randomUUID();
|
||||
let complete = 0;
|
||||
let retData: Parameters<ListenerType> | undefined;
|
||||
let retEvent: any = {};
|
||||
|
||||
function sendDataCallback (resolve: any, reject: any) {
|
||||
if (complete === 0) {
|
||||
reject(
|
||||
new Error(
|
||||
'Timeout: NTEvent serviceAndMethod:' +
|
||||
serviceAndMethod +
|
||||
' ListenerName:' +
|
||||
listenerAndMethod +
|
||||
' EventRet:\n' +
|
||||
JSON.stringify(retEvent, null, 4) +
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
resolve([retEvent as Awaited<ReturnType<EventType>>, ...retData!]);
|
||||
}
|
||||
}
|
||||
|
||||
const ListenerNameList = listenerAndMethod.split('/');
|
||||
const ListenerMainName = ListenerNameList[0] ?? '';
|
||||
const ListenerSubName = ListenerNameList[1] ?? '';
|
||||
|
||||
return new Promise<[EventRet: Awaited<ReturnType<EventType>>, ...Parameters<ListenerType>]>(
|
||||
(resolve, reject) => {
|
||||
const timeoutRef = setTimeout(() => sendDataCallback(resolve, reject), timeout);
|
||||
|
||||
const eventCallback = {
|
||||
timeout,
|
||||
createtime: Date.now(),
|
||||
checker: checkerListener,
|
||||
func: (...args: any[]) => {
|
||||
complete++;
|
||||
retData = args as Parameters<ListenerType>;
|
||||
if (complete >= callbackTimesToWait) {
|
||||
clearTimeout(timeoutRef);
|
||||
sendDataCallback(resolve, reject);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (!this.EventTask.get(ListenerMainName)) {
|
||||
this.EventTask.set(ListenerMainName, new Map());
|
||||
}
|
||||
if (!this.EventTask.get(ListenerMainName)?.get(ListenerSubName)) {
|
||||
this.EventTask.get(ListenerMainName)?.set(ListenerSubName, new Map());
|
||||
}
|
||||
this.EventTask.get(ListenerMainName)?.get(ListenerSubName)?.set(id, eventCallback);
|
||||
this.createListenerFunction(ListenerMainName);
|
||||
|
||||
const eventResult = this.createEventFunction(serviceAndMethod)!(...(args));
|
||||
|
||||
const eventRetHandle = (eventData: any) => {
|
||||
retEvent = eventData;
|
||||
if (!checkerEvent(retEvent) && timeoutRef.hasRef()) {
|
||||
clearTimeout(timeoutRef);
|
||||
reject(
|
||||
new Error(
|
||||
'EventChecker Failed: NTEvent serviceAndMethod:' +
|
||||
serviceAndMethod +
|
||||
' ListenerName:' +
|
||||
listenerAndMethod +
|
||||
' EventRet:\n' +
|
||||
JSON.stringify(retEvent, null, 4) +
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
if (eventResult instanceof Promise) {
|
||||
eventResult.then((eventResult: any) => {
|
||||
eventRetHandle(eventResult);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
eventRetHandle(eventResult);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
42
packages/napcat-common/src/fall-back.ts
Normal file
42
packages/napcat-common/src/fall-back.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
type Handler<T> = () => T | Promise<T>;
|
||||
type Checker<T> = (result: T) => T | Promise<T>;
|
||||
|
||||
export class Fallback<T> {
|
||||
private handlers: Handler<T>[] = [];
|
||||
private checker: Checker<T>;
|
||||
|
||||
constructor (checker?: Checker<T>) {
|
||||
this.checker = checker || (async (result: T) => result);
|
||||
}
|
||||
|
||||
add (handler: Handler<T>): this {
|
||||
this.handlers.push(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
// 执行处理程序链
|
||||
async run (): Promise<T> {
|
||||
const errors: Error[] = [];
|
||||
for (const handler of this.handlers) {
|
||||
try {
|
||||
const result = await handler();
|
||||
const data = await this.checker(result);
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
throw new AggregateError(errors, 'All handlers failed');
|
||||
}
|
||||
}
|
||||
export class FallbackUtil {
|
||||
static boolchecker<T>(value: T, condition: boolean): T {
|
||||
if (condition) {
|
||||
return value;
|
||||
} else {
|
||||
throw new Error('Condition is false, throwing error');
|
||||
}
|
||||
}
|
||||
}
|
||||
130
packages/napcat-common/src/ffmpeg-adapter-factory.ts
Normal file
130
packages/napcat-common/src/ffmpeg-adapter-factory.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* FFmpeg Adapter Factory
|
||||
* 自动检测并选择最佳的 FFmpeg 适配器
|
||||
*/
|
||||
|
||||
import { LogWrapper } from './log';
|
||||
import { FFmpegAddonAdapter } from './ffmpeg-addon-adapter';
|
||||
import { FFmpegExecAdapter } from './ffmpeg-exec-adapter';
|
||||
import type { IFFmpegAdapter } from './ffmpeg-adapter-interface';
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器工厂
|
||||
*/
|
||||
export class FFmpegAdapterFactory {
|
||||
private static instance: IFFmpegAdapter | null = null;
|
||||
private static initPromise: Promise<IFFmpegAdapter> | null = null;
|
||||
|
||||
/**
|
||||
* 初始化并获取最佳的 FFmpeg 适配器
|
||||
* @param logger 日志记录器
|
||||
* @param ffmpegPath FFmpeg 可执行文件路径(用于 Exec 适配器)
|
||||
* @param ffprobePath FFprobe 可执行文件路径(用于 Exec 适配器)
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath,用于 Addon 适配器)
|
||||
*/
|
||||
static async getAdapter (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string = 'ffmpeg',
|
||||
ffprobePath: string = 'ffprobe',
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
// 如果已经初始化,直接返回
|
||||
if (this.instance) {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// 如果正在初始化,等待初始化完成
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
// 开始初始化
|
||||
this.initPromise = this.initialize(logger, ffmpegPath, ffprobePath, binaryPath);
|
||||
|
||||
try {
|
||||
this.instance = await this.initPromise;
|
||||
return this.instance;
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化适配器
|
||||
*/
|
||||
private static async initialize (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string,
|
||||
binaryPath?: string
|
||||
): Promise<IFFmpegAdapter> {
|
||||
// 1. 优先尝试使用 Native Addon
|
||||
if (binaryPath) {
|
||||
const addonAdapter = new FFmpegAddonAdapter(binaryPath);
|
||||
|
||||
logger.log('[FFmpeg] 检查 Native Addon 可用性...');
|
||||
if (await addonAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] ✓ 使用 Native Addon 适配器');
|
||||
return addonAdapter;
|
||||
}
|
||||
|
||||
logger.log('[FFmpeg] Native Addon 不可用,尝试使用命令行工具');
|
||||
} else {
|
||||
logger.log('[FFmpeg] 未提供 binaryPath,跳过 Native Addon 检测');
|
||||
}
|
||||
|
||||
// 2. 降级到 execFile 实现
|
||||
const execAdapter = new FFmpegExecAdapter(ffmpegPath, ffprobePath, binaryPath, logger);
|
||||
|
||||
logger.log(`[FFmpeg] 检查命令行工具可用性: ${ffmpegPath}`);
|
||||
if (await execAdapter.isAvailable()) {
|
||||
logger.log('[FFmpeg] 使用命令行工具适配器 ✓');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
// 3. 都不可用,返回 execAdapter 但会在使用时报错
|
||||
logger.logError('[FFmpeg] 警告: FFmpeg 不可用,将使用命令行适配器但可能失败');
|
||||
return execAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置适配器(用于测试或重新初始化)
|
||||
*/
|
||||
static reset (): void {
|
||||
this.instance = null;
|
||||
this.initPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 FFmpeg 路径并重新初始化
|
||||
* @param logger 日志记录器
|
||||
* @param ffmpegPath FFmpeg 可执行文件路径
|
||||
* @param ffprobePath FFprobe 可执行文件路径
|
||||
*/
|
||||
static async updateFFmpegPath (
|
||||
logger: LogWrapper,
|
||||
ffmpegPath: string,
|
||||
ffprobePath: string
|
||||
): Promise<void> {
|
||||
// 如果当前使用的是 Exec 适配器,更新路径
|
||||
if (this.instance && this.instance instanceof FFmpegExecAdapter) {
|
||||
logger.log(`[FFmpeg] 更新 FFmpeg 路径: ${ffmpegPath}`);
|
||||
this.instance.setFFmpegPath(ffmpegPath);
|
||||
this.instance.setFFprobePath(ffprobePath);
|
||||
|
||||
// 验证新路径是否可用
|
||||
if (await this.instance.isAvailable()) {
|
||||
logger.log('[FFmpeg] 新路径验证成功 ✓');
|
||||
} else {
|
||||
logger.logError('[FFmpeg] 警告: 新 FFmpeg 路径不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前适配器(不初始化)
|
||||
*/
|
||||
static getCurrentAdapter (): IFFmpegAdapter | null {
|
||||
return this.instance;
|
||||
}
|
||||
}
|
||||
68
packages/napcat-common/src/ffmpeg-adapter-interface.ts
Normal file
68
packages/napcat-common/src/ffmpeg-adapter-interface.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* FFmpeg Adapter Interface
|
||||
* 定义统一的 FFmpeg 操作接口,支持多种实现方式
|
||||
*/
|
||||
|
||||
/**
|
||||
* 视频信息结果
|
||||
*/
|
||||
export interface VideoInfoResult {
|
||||
/** 视频宽度(像素) */
|
||||
width: number;
|
||||
/** 视频高度(像素) */
|
||||
height: number;
|
||||
/** 视频时长(秒) */
|
||||
duration: number;
|
||||
/** 容器格式 */
|
||||
format: string;
|
||||
/** 缩略图 Buffer */
|
||||
thumbnail?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg 适配器接口
|
||||
*/
|
||||
export interface IFFmpegAdapter {
|
||||
/** 适配器名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 是否可用 */
|
||||
isAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 获取视频信息(包含缩略图)
|
||||
* @param videoPath 视频文件路径
|
||||
* @returns 视频信息
|
||||
*/
|
||||
getVideoInfo(videoPath: string): Promise<VideoInfoResult>;
|
||||
|
||||
/**
|
||||
* 获取音视频文件时长
|
||||
* @param filePath 文件路径
|
||||
* @returns 时长(秒)
|
||||
*/
|
||||
getDuration(filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* 转换音频为 PCM 格式
|
||||
* @param filePath 输入文件路径
|
||||
* @param pcmPath 输出 PCM 文件路径
|
||||
* @returns PCM 数据 Buffer
|
||||
*/
|
||||
convertToPCM(filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number }>;
|
||||
|
||||
/**
|
||||
* 转换音频文件
|
||||
* @param inputFile 输入文件路径
|
||||
* @param outputFile 输出文件路径
|
||||
* @param format 目标格式 ('amr' | 'silk' 等)
|
||||
*/
|
||||
convertFile(inputFile: string, outputFile: string, format: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 提取视频缩略图
|
||||
* @param videoPath 视频文件路径
|
||||
* @param thumbnailPath 缩略图输出路径
|
||||
*/
|
||||
extractThumbnail(videoPath: string, thumbnailPath: string): Promise<void>;
|
||||
}
|
||||
119
packages/napcat-common/src/ffmpeg-addon-adapter.ts
Normal file
119
packages/napcat-common/src/ffmpeg-addon-adapter.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* FFmpeg Native Addon Adapter
|
||||
* 使用原生 Node.js Addon 实现的 FFmpeg 适配器
|
||||
*/
|
||||
|
||||
import { platform, arch } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { FFmpeg } from './ffmpeg-addon';
|
||||
import type { IFFmpegAdapter, VideoInfoResult } from './ffmpeg-adapter-interface';
|
||||
import { dlopen } from 'node:process';
|
||||
|
||||
/**
|
||||
* 获取 Native Addon 路径
|
||||
* @param binaryPath 二进制文件路径(来自 pathWrapper.binaryPath)
|
||||
*/
|
||||
function getAddonPath (binaryPath: string): string {
|
||||
const platformName = platform();
|
||||
const archName = arch();
|
||||
|
||||
const addonFileName: string = process.platform + '.' + process.arch;
|
||||
const addonPath = path.join(binaryPath, './native/ffmpeg/', `ffmpegAddon.${addonFileName}.node`);
|
||||
if (!existsSync(addonPath)) {
|
||||
throw new Error(`Unsupported platform: ${platformName} ${archName} - Addon not found at ${addonPath}`);
|
||||
}
|
||||
return addonPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg Native Addon 适配器实现
|
||||
*/
|
||||
export class FFmpegAddonAdapter implements IFFmpegAdapter {
|
||||
public readonly name = 'FFmpegAddon';
|
||||
private addon: FFmpeg | null = null;
|
||||
private binaryPath: string;
|
||||
|
||||
constructor (binaryPath: string) {
|
||||
this.binaryPath = binaryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Addon 是否可用
|
||||
*/
|
||||
async isAvailable (): Promise<boolean> {
|
||||
try {
|
||||
const temp_addon = { exports: {} };
|
||||
dlopen(temp_addon, getAddonPath(this.binaryPath));
|
||||
this.addon = temp_addon.exports as FFmpeg;
|
||||
return this.addon !== null;
|
||||
} catch (error) {
|
||||
console.log('[FFmpegAddonAdapter] Failed to load addon:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureAddon (): FFmpeg {
|
||||
if (!this.addon) {
|
||||
throw new Error('FFmpeg Addon is not available');
|
||||
}
|
||||
return this.addon;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*/
|
||||
async getVideoInfo (videoPath: string): Promise<VideoInfoResult> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath);
|
||||
|
||||
let format = info.format.includes(',') ? info.format.split(',')[0] ?? info.format : info.format;
|
||||
console.log('[FFmpegAddonAdapter] Detected format:', format);
|
||||
return {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
duration: info.duration,
|
||||
format: format,
|
||||
thumbnail: info.image,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取时长
|
||||
*/
|
||||
async getDuration (filePath: string): Promise<number> {
|
||||
const addon = this.ensureAddon();
|
||||
return addon.getDuration(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PCM
|
||||
*/
|
||||
async convertToPCM (filePath: string, pcmPath: string): Promise<{ result: boolean, sampleRate: number; }> {
|
||||
const addon = this.ensureAddon();
|
||||
const result = await addon.decodeAudioToPCM(filePath, pcmPath, 24000);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换文件
|
||||
*/
|
||||
async convertFile (inputFile: string, outputFile: string, format: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
console.log('[FFmpegAddonAdapter] Converting file:', inputFile, 'to', outputFile, 'as', format);
|
||||
await addon.decodeAudioToFmt(inputFile, outputFile, format);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取缩略图
|
||||
*/
|
||||
async extractThumbnail (videoPath: string, thumbnailPath: string): Promise<void> {
|
||||
const addon = this.ensureAddon();
|
||||
const info = await addon.getVideoInfo(videoPath);
|
||||
|
||||
// 将缩略图写入文件
|
||||
await writeFile(thumbnailPath, info.image);
|
||||
}
|
||||
}
|
||||
73
packages/napcat-common/src/ffmpeg-addon.ts
Normal file
73
packages/napcat-common/src/ffmpeg-addon.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* FFmpeg Node.js Native Addon Type Definitions
|
||||
*
|
||||
* This addon provides FFmpeg functionality for Node.js including:
|
||||
* - Video information extraction with thumbnail generation
|
||||
* - Audio/Video duration detection
|
||||
* - Audio format conversion to NTSILK
|
||||
* - Audio decoding to PCM
|
||||
*/
|
||||
|
||||
/**
|
||||
* Video information result object
|
||||
*/
|
||||
export interface VideoInfo {
|
||||
/** Video width in pixels */
|
||||
width: number;
|
||||
|
||||
/** Video height in pixels */
|
||||
height: number;
|
||||
|
||||
/** Video duration in seconds */
|
||||
duration: number;
|
||||
|
||||
/** Container format name (e.g., "mp4", "mkv", "avi") */
|
||||
format: string;
|
||||
|
||||
/** Video codec name (e.g., "h264", "hevc", "vp9") */
|
||||
videoCodec: string;
|
||||
|
||||
/** First frame thumbnail as BMP image buffer */
|
||||
image: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio PCM decoding result object
|
||||
*/
|
||||
export interface AudioPCMResult {
|
||||
/** PCM audio data as 16-bit signed integer samples */
|
||||
pcm: Buffer;
|
||||
|
||||
/** Sample rate in Hz (e.g., 44100, 48000, 24000) */
|
||||
sampleRate: number;
|
||||
|
||||
/** Number of audio channels (1 for mono, 2 for stereo) */
|
||||
channels: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FFmpeg interface providing all audio/video processing methods
|
||||
*/
|
||||
export interface FFmpeg {
|
||||
convertFile (inputFile: string, outputFile: string, format: string): Promise<{ success: boolean; }>;
|
||||
/**
|
||||
* Get video information including resolution, duration, format, codec and first frame thumbnail
|
||||
*/
|
||||
getVideoInfo (filePath: string, format?: 'bmp' | 'bmp24'): Promise<VideoInfo>;
|
||||
|
||||
/**
|
||||
* Get duration of audio or video file in seconds
|
||||
*/
|
||||
getDuration (filePath: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Convert audio file to NTSILK format (WeChat voice message format)
|
||||
*/
|
||||
convertToNTSilkTct (inputPath: string, outputPath: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Decode audio file to raw PCM data
|
||||
*/
|
||||
decodeAudioToPCM (filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean, sampleRate: number; }>;
|
||||
decodeAudioToFmt (filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string; }>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user